Compare commits
111 Commits
55781984c7
...
chore/vend
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cd4cf98c3 | |||
| 38c472a118 | |||
| 1d0197da69 | |||
| 0920c4cb6d | |||
| 2254a07baf | |||
| 1b9c761274 | |||
| a126cfa710 | |||
| 86fa57b5cd | |||
| 8e97d589af | |||
| 0dd8c28815 | |||
| 4e294b3630 | |||
| 85f0bb67fa | |||
| c3f478b8f8 | |||
| 1aaedf498a | |||
| 20adb89650 | |||
| 058f1afcdc | |||
| 2b73290994 | |||
| 428f4fe945 | |||
| 195d45a0e9 | |||
| 5b68bdc16c | |||
| 757ef2f34b | |||
| b27e1bf3ec | |||
| 39e9227fdb | |||
| bb05f215c6 | |||
| 467589d78a | |||
| e25e979757 | |||
| bc0ccb4cdc | |||
| c9a00a9532 | |||
| eb16796bf0 | |||
| a3914a1d41 | |||
| 8eb1ca2bac | |||
| e4104eb344 | |||
| 0929c17cbb | |||
| 6487032461 | |||
| fb6b6f9160 | |||
| 62c9fc5c86 | |||
| 6007e922b4 | |||
| 2d8eb91b25 | |||
| 3d88f52988 | |||
| 9016c3a43b | |||
| 04775192c1 | |||
| a104cb4bb4 | |||
| 72f0f46528 | |||
| b87735a111 | |||
| 1540f74342 | |||
| c7729b2ef6 | |||
| b8b4aa2aee | |||
| c10f6920cd | |||
| ad29295c93 | |||
| 6ca6d5b352 | |||
| af450dac2a | |||
| 6ed9dc39e8 | |||
| cc8eae4979 | |||
| 4f706fa65f | |||
| 50e899e5ee | |||
| 42751623ea | |||
| 98e19bb14a | |||
| d7b526a112 | |||
| 1dca2cfd7a | |||
| 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
|
||||||
|
|||||||
87
.agents/skills/knowledge-graph-audit/SKILL.md
Normal file
87
.agents/skills/knowledge-graph-audit/SKILL.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
name: knowledge-graph-audit
|
||||||
|
risk_class: read_only
|
||||||
|
inputs: []
|
||||||
|
verification: "audit_knowledge_graph returns a report with summary.total_findings"
|
||||||
|
docs_update_checklist: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Knowledge-graph audit
|
||||||
|
|
||||||
|
Goal: validate that the knowledge graph (entities, relationships, checks) and
|
||||||
|
the monitoring built on it reflect live reality — without mutating anything.
|
||||||
|
Read-only. Run this before trusting health, blast-radius, or coverage answers,
|
||||||
|
and whenever something feels off (a healthy host reports `down`, a retired
|
||||||
|
service still alarms, the graph looks thin).
|
||||||
|
|
||||||
|
## 1. Run the drift report
|
||||||
|
|
||||||
|
Call MCP `audit_knowledge_graph` (or `GET /api/v1/audit/drift`). It returns a
|
||||||
|
ranked list of findings, each with `{category, severity, count, entities,
|
||||||
|
evidence, suggested_runbook}`, plus a `summary` with totals by category.
|
||||||
|
|
||||||
|
The DB-side categories:
|
||||||
|
|
||||||
|
- **orphan_checks** — check entities with truncated/random slugs left by the
|
||||||
|
old `shortSlug()` collision bug. Remediation: `scripts/cleanup-orphan-checks.sh`.
|
||||||
|
- **dead_checks** — enabled `check_defs` whose target entity is `deprecated`/
|
||||||
|
`destroyed`. Remediation: `lifecycle-deprecate-node` / `lifecycle-destroy-node`
|
||||||
|
(the scheduler already skips these, but the rows should be retired).
|
||||||
|
- **down_checks** — enabled probes reporting `down`. Remediation:
|
||||||
|
`service-health-check` (then check whether the failure is real or a
|
||||||
|
probe-config/routing problem — see step 3).
|
||||||
|
- **unknown_checks** — probes that ran but reported `unknown` (usually a
|
||||||
|
misconfigured or not-yet-deployed probe script).
|
||||||
|
- **unmonitored** — active entities whose type declares monitoring but have no
|
||||||
|
enabled `check_def`.
|
||||||
|
- **dangling_edges** — live `hosts`/`provides`/`mounts` edges still pointing at
|
||||||
|
destroyed/deprecated targets. Remediation: `lifecycle-destroy-node`.
|
||||||
|
|
||||||
|
## 2. Triage
|
||||||
|
|
||||||
|
`severity: critical` (down_checks) first. For each finding, read `evidence` and
|
||||||
|
open the entities with `get_entity` / `get_relations` to confirm the diagnosis
|
||||||
|
before acting — the report is a pointer, not a verdict.
|
||||||
|
|
||||||
|
## 3. Common probe-failure causes
|
||||||
|
|
||||||
|
A `down_checks` finding that is NOT a real outage is usually one of:
|
||||||
|
|
||||||
|
- **Guest reached wrong** — an LXC/VM check SSHed the guest directly instead of
|
||||||
|
routing through its Proxmox host. Confirm with `get_relations` that a `hosts`
|
||||||
|
edge exists and the guest has `pve_id`; checks route via `pct exec`/`qm guest
|
||||||
|
exec` automatically when both are present.
|
||||||
|
- **Script not deployed** — the probe script is absent at `/opt/oikos/checks/`
|
||||||
|
inside the target. Remediation: redeploy via `tools/deploy-checks.sh`.
|
||||||
|
- **macOS host** — a workstation check used the wrong SSH user or a Linux-only
|
||||||
|
script flag. The scheduler resolves `user: dtoro` from the entity attribute.
|
||||||
|
|
||||||
|
## 4. What this audit does NOT cover (follow-ups)
|
||||||
|
|
||||||
|
Live-infrastructure discovery has its own tool — run **`discover_infra_drift`**
|
||||||
|
alongside this one. It compares running Proxmox guests (`pct`/`qm list` on every
|
||||||
|
proxmox host) against the DB graph and returns:
|
||||||
|
|
||||||
|
- **missing entities** — a guest running in Proxmox with no DB entity.
|
||||||
|
- **ghost entities** — a DB lxc/vm whose `pve_id` is no longer live.
|
||||||
|
|
||||||
|
Still manual until that machinery lands:
|
||||||
|
|
||||||
|
- **Misplaced parent** — compare each guest's actual Proxmox host against its
|
||||||
|
`hosts` edge (migrations leave these stale).
|
||||||
|
- **Undeployed scripts** — per-guest `/opt/oikos/checks/` presence.
|
||||||
|
- **Unmodeled certs** — now modeled; verify with `audit_knowledge_graph` /
|
||||||
|
the cert-expiry checks.
|
||||||
|
- **Seed drift** — run `oikos export` and `git diff seeds/` to find
|
||||||
|
runtime-created entities not in version control.
|
||||||
|
|
||||||
|
## 5. Acting on findings
|
||||||
|
|
||||||
|
This skill is read-only — make no changes here. Route each confirmed finding to
|
||||||
|
its `suggested_runbook`, classify the action against `seeds/policy.yaml`, and
|
||||||
|
proceed through the normal lifecycle/approval flow. Re-run the audit afterward
|
||||||
|
to confirm the finding cleared.
|
||||||
|
|
||||||
|
Docs-update checklist: none — the audit reads state; it changes nothing. If a
|
||||||
|
finding reveals stale `risk_notes` or a wrong `doc_page`, fix `inventory.yaml`
|
||||||
|
in that remediation session.
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Every docker build in this repo previously sent the whole directory as
|
||||||
|
# build context — including every OTHER git worktree under .claude/worktrees/
|
||||||
|
# (each with its own web/node_modules, ~200-300MB apiece). That's what
|
||||||
|
# starved the mac-mini's disk mid-build on 2026-07-27 (SHA 873b00a): the
|
||||||
|
# context alone crossed 390MB of pure worktree cruft before the host ran out
|
||||||
|
# of space. None of this ever belonged in an image.
|
||||||
|
.claude/worktrees/
|
||||||
|
.git/
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/build/
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,3 +24,7 @@ cmd/desktop/build/
|
|||||||
cmd/desktop/Oikos
|
cmd/desktop/Oikos
|
||||||
desktop
|
desktop
|
||||||
/eval
|
/eval
|
||||||
|
|
||||||
|
# Local tooling artifacts (Playwright MCP session logs, stray screenshots)
|
||||||
|
.playwright-mcp/
|
||||||
|
config-screen.png
|
||||||
|
|||||||
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)
|
||||||
|
|||||||
@@ -2284,6 +2284,23 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
version:
|
version:
|
||||||
type: integer
|
type: integer
|
||||||
|
last_health:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
This check's own most recent verdict. An entity's health is the
|
||||||
|
worst of these across its enabled checks, so this is what explains
|
||||||
|
*why* an entity is degraded. Null until the check first runs.
|
||||||
|
nullable: true
|
||||||
|
enum:
|
||||||
|
- healthy
|
||||||
|
- degraded
|
||||||
|
- down
|
||||||
|
- unknown
|
||||||
|
last_run_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: When this check last executed. Null = never run.
|
||||||
|
nullable: true
|
||||||
CheckCreate:
|
CheckCreate:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|||||||
104
archive/knowledge/infrastructure/oikos-check-lifecycle.md
Normal file
104
archive/knowledge/infrastructure/oikos-check-lifecycle.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Oikos check lifecycle — how monitoring works
|
||||||
|
|
||||||
|
This runbook covers how Oikos health checks are derived, created, and wired so
|
||||||
|
an agent (Nomos) doesn't reverse-engineer source when asked to add monitoring to
|
||||||
|
an entity — the problem that stranded session `23da10db` (2026-08-03).
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- **`check_defs`** (scheduler config, table `check_defs`): the row the scheduler
|
||||||
|
reads to know *what* to probe and *when*. One per check instance.
|
||||||
|
- **`check` entity** (type `check`, slug `check:<kind>:<target>:<n>`): the
|
||||||
|
knowledge-graph entity for that check. It carries attributes
|
||||||
|
(`check_type`, `target`, `port`, …) and `checks` edges to the probed target.
|
||||||
|
- **`monitoring` spec** on an entity type (`entity_types.monitoring_spec`): the
|
||||||
|
default list of check kinds (e.g. `[http, process]` for `service`).
|
||||||
|
- Per-entity override: set `monitoring` in the entity's attributes —
|
||||||
|
`"none"` for zero checks, `["http"]` to replace the type defaults.
|
||||||
|
- **`checkdefaults.Ensure`** (`internal/checkdefaults/defaults.go`): the
|
||||||
|
function that reads the monitoring spec, resolves host/port/URL from
|
||||||
|
attributes + relationships, and writes `check_defs` rows. Idempotent.
|
||||||
|
|
||||||
|
## When checks are derived
|
||||||
|
|
||||||
|
`checkdefaults.Ensure` runs in three situations (as of v0.17.1+):
|
||||||
|
|
||||||
|
1. **Seed/deploy ingest** — `internal/db/seed.go:231`. Every entity gets its
|
||||||
|
default checks once on initial ingest.
|
||||||
|
2. **HTTP `POST /api/v1/entities` (create)** — `ensureDefaultChecks` at
|
||||||
|
`internal/httpapi/impl.go:1012`. Creating an entity via the REST API derives
|
||||||
|
its checks in the same transaction.
|
||||||
|
3. **HTTP `PATCH /api/v1/entities` (patch)** — `ensureDefaultChecks` at
|
||||||
|
`internal/httpapi/impl.go:1280`. Changing an entity's attributes (especially
|
||||||
|
`monitoring`) via the REST API regenerates its checks.
|
||||||
|
4. **MCP `create_entity`** — SAME hook. Creating an entity via the MCP tool
|
||||||
|
derives checks. (Added 2026-08-03; previously MCP had no create.)
|
||||||
|
5. **MCP `update_entity_attributes`** — SAME hook. Changing an entity's
|
||||||
|
`monitoring` attribute via MCP now regenerates checks. (Added 2026-08-03;
|
||||||
|
previously MCP updates silently skipped check derivation — the exact bug
|
||||||
|
that stranded the haos session.)
|
||||||
|
|
||||||
|
## Check slug grammar
|
||||||
|
|
||||||
|
```
|
||||||
|
check:<kind>:<target-type>:<target-name>:<n>
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples: `check:http:service:jellyfin:0`, `check:vm-status:vm:haos:0`,
|
||||||
|
`check:cert-expiry:cert:house.hubris.network:0`.
|
||||||
|
|
||||||
|
## Adding monitoring to an entity
|
||||||
|
|
||||||
|
**If the entity already exists:**
|
||||||
|
|
||||||
|
```
|
||||||
|
update_entity_attributes(slug="service:haos", attributes={"monitoring":["http"]})
|
||||||
|
```
|
||||||
|
|
||||||
|
This regenerates checks via `checkdefaults.Ensure`. The result message tells you
|
||||||
|
how many checks were derived and whether any kinds were skipped (and why).
|
||||||
|
|
||||||
|
**If the entity does not exist yet (a new check, ingress, cert, etc.):**
|
||||||
|
|
||||||
|
```
|
||||||
|
create_entity(type="check", name="HAOS http check",
|
||||||
|
slug="check:http:service:haos:0",
|
||||||
|
attributes={"check_type":"http:service","target":"service:haos","port":"8123"})
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the entity AND derives its `check_defs`. Same for a new `ingress`
|
||||||
|
(`type=ingress`, monitoring `[http]`) or `cert` (`type=cert`,
|
||||||
|
monitoring `[cert-expiry]`).
|
||||||
|
|
||||||
|
**To remove monitoring:** set `monitoring:["none"]` or transition the entity
|
||||||
|
to a terminal lifecycle state (`set_entity_state` → `deprecated`/`destroyed`).
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- **A service without a `url` attribute AND without a `probe_unit` gets no
|
||||||
|
process check** (the http check covers liveness; the process check would
|
||||||
|
be redundant without an opt-in `probe_unit`). The skip is logged.
|
||||||
|
- **A service whose address comes from a `hosts` edge** may produce no checks on
|
||||||
|
initial create because the edge doesn't exist yet — the next inventory ingest
|
||||||
|
(or a later `update_entity_attributes` after the edge is created) fills it in.
|
||||||
|
- **A `not found` error from `update_entity_attributes`** means the entity
|
||||||
|
doesn't exist — use `create_entity` instead.
|
||||||
|
- **`check_defs` has target columns** (`target_id`, `target_type`). A check
|
||||||
|
entity needs a `checks` relationship (`create_relationship(source=check:…,
|
||||||
|
target=service:…, type="checks")`) so the scheduler can resolve what to
|
||||||
|
probe. `create_entity` derives the check_def; `create_relationship` links
|
||||||
|
the check entity to its target in the graph.
|
||||||
|
|
||||||
|
## Related files
|
||||||
|
|
||||||
|
- `internal/checkdefaults/defaults.go` — `Ensure`, `Target`, `LogResult`
|
||||||
|
- `internal/httpapi/default_checks.go` — `ensureDefaultChecks` (HTTP hook)
|
||||||
|
- `internal/db/checks.go` — `db.EnsureEntityChecks` (shared hook)
|
||||||
|
- `internal/db/seed.go` — seed-time check derivation
|
||||||
|
- `internal/mcp/tools.go` — `create_entity`, `update_entity_attributes`
|
||||||
|
|
||||||
|
## Revision history
|
||||||
|
|
||||||
|
- **2026-08-03:** Created after session `23da10db` stranded for lack of entity-
|
||||||
|
creation tool and unawareness of check-derivation triggers. Covers the MCP
|
||||||
|
create_entity + update_entity_attributes regen paths added same day.
|
||||||
@@ -2,12 +2,27 @@
|
|||||||
# cpu_check.sh — CPU usage % and thermal temperature.
|
# cpu_check.sh — CPU usage % and thermal temperature.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
USAGE=$(top -bn1 2>/dev/null | awk '/^%Cpu/ {print 100 - $8}' || true)
|
os=$(uname -s)
|
||||||
if [ -z "$USAGE" ]; then
|
|
||||||
CORES=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
|
if [ "$os" = "Darwin" ]; then
|
||||||
USAGE=$(awk -v cores="$CORES" '{print ($1+$2+$3)*100/cores}' /proc/loadavg 2>/dev/null || echo "0")
|
# `top -l 1 -n 0` prints "CPU usage: X% user, Y% sys, Z% idle".
|
||||||
|
# Usage is 100 minus the idle figure that precedes the literal `idle`.
|
||||||
|
USAGE=$(top -l 1 -n 0 -s 0 2>/dev/null | awk '
|
||||||
|
/^CPU usage/ {
|
||||||
|
for (i = 1; i <= NF; i++) {
|
||||||
|
if ($i == "idle") { gsub(/%/, "", $(i - 1)); printf "%.1f", 100 - $(i - 1) }
|
||||||
|
}
|
||||||
|
}' || true)
|
||||||
|
else
|
||||||
|
USAGE=$(top -bn1 2>/dev/null | awk '/^%Cpu/ {print 100 - $8}' || true)
|
||||||
|
if [ -z "$USAGE" ]; then
|
||||||
|
CORES=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
|
||||||
|
USAGE=$(awk -v cores="$CORES" '{print ($1+$2+$3)*100/cores}' /proc/loadavg 2>/dev/null || echo "0")
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
[ -z "$USAGE" ] && USAGE=0
|
||||||
|
|
||||||
TEMP=""
|
TEMP=""
|
||||||
if [ -f /sys/class/thermal/thermal_zone0/temp ]; then
|
if [ -f /sys/class/thermal/thermal_zone0/temp ]; then
|
||||||
TEMP=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp 2>/dev/null || true)
|
TEMP=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp 2>/dev/null || true)
|
||||||
|
|||||||
22
checks/disk_usage_check.sh
Normal file → Executable file
22
checks/disk_usage_check.sh
Normal file → Executable file
@@ -2,18 +2,34 @@
|
|||||||
# disk_usage_check.sh — disk usage and inode usage per mountpoint.
|
# disk_usage_check.sh — disk usage and inode usage per mountpoint.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
MOUNTS=$(df -k 2>/dev/null | awk 'NR>1 && $1 ~ /^\// && $NF !~ /^\/(snap|dev|proc|sys|run|private)/ {print $NF}' || true)
|
# `timeout` caps each df so a single hung/stale mountpoint (a stale NFS
|
||||||
|
# export, a wedged ZFS pool) can't stall the whole check — that hung the
|
||||||
|
# scheduler's 30s budget on hubris. Available on Linux (coreutils); absent on
|
||||||
|
# Darwin, whose local mounts don't hang, so it degrades to an empty prefix.
|
||||||
|
TO=""
|
||||||
|
if command -v timeout >/dev/null 2>&1; then TO="timeout 8"; fi
|
||||||
|
|
||||||
|
# Build the mount list WITHOUT statting anything: reading /proc/mounts never
|
||||||
|
# blocks the way `df` does on a stuck filesystem, so the enumeration itself
|
||||||
|
# can't hang. Fall back to `df` on hosts without /proc/mounts (macOS).
|
||||||
|
if [ -r /proc/mounts ]; then
|
||||||
|
MOUNTS=$(awk '$1 ~ /^\// && $2 !~ /^\/(snap|dev|proc|sys|run|private)/ {print $2}' /proc/mounts || true)
|
||||||
|
else
|
||||||
|
MOUNTS=$($TO df -k 2>/dev/null | awk 'NR>1 && $1 ~ /^\// && $NF !~ /^\/(snap|dev|proc|sys|run|private)/ {print $NF}' || true)
|
||||||
|
fi
|
||||||
FIRST=1
|
FIRST=1
|
||||||
|
|
||||||
echo -n '{"health":"healthy","metrics":{'
|
echo -n '{"health":"healthy","metrics":{'
|
||||||
for m in $MOUNTS; do
|
for m in $MOUNTS; do
|
||||||
LINE=$(df -k "$m" 2>/dev/null | awk 'NR==2 {print $3, $4, $5, $7}' | tr -d '%' || true)
|
# Each df is bounded: a stuck mount times out and is skipped (LINE empty)
|
||||||
|
# rather than hanging the probe.
|
||||||
|
LINE=$($TO df -k "$m" 2>/dev/null | awk 'NR==2 {print $3, $4, $5, $7}' | tr -d '%' || true)
|
||||||
if [ -z "$LINE" ]; then continue; fi
|
if [ -z "$LINE" ]; then continue; fi
|
||||||
USED=$(echo "$LINE" | awk '{print $1}')
|
USED=$(echo "$LINE" | awk '{print $1}')
|
||||||
FREE=$(echo "$LINE" | awk '{print $2}')
|
FREE=$(echo "$LINE" | awk '{print $2}')
|
||||||
PCT=$(echo "$LINE" | awk '{print $3}')
|
PCT=$(echo "$LINE" | awk '{print $3}')
|
||||||
|
|
||||||
INODE_LINE=$(df -i "$m" 2>/dev/null | awk 'NR==2 {print $5}' | tr -d '%' || echo "0")
|
INODE_LINE=$($TO df -i "$m" 2>/dev/null | awk 'NR==2 {print $5}' | tr -d '%' || echo "0")
|
||||||
INODE_PCT=$(echo "${INODE_LINE:-0}" | sed 's/-/0/')
|
INODE_PCT=$(echo "${INODE_LINE:-0}" | sed 's/-/0/')
|
||||||
|
|
||||||
KEY=$(echo "$m" | sed 's|/|_|g' | sed 's|^_||')
|
KEY=$(echo "$m" | sed 's|/|_|g' | sed 's|^_||')
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# process_check.sh — systemd service liveness.
|
# process_check.sh — service liveness.
|
||||||
|
#
|
||||||
|
# A service entity's name is a logical label, rarely the literal systemd unit
|
||||||
|
# or container name. matrix = matrix-synapse.service + element-web/mautrix-*
|
||||||
|
# containers; authentik = authentik-server/-worker containers. So checking
|
||||||
|
# `systemctl is-active matrix` reports "inactive" for a healthy service.
|
||||||
|
#
|
||||||
|
# Resolution order, any hit = healthy:
|
||||||
|
# 1. exact systemd unit `systemctl is-active <name>`
|
||||||
|
# 2. a systemd unit with the name as prefix `<name>*.service`
|
||||||
|
# 3. a running docker container whose name contains <name>
|
||||||
|
# An explicit probe target overrides the label — see checkdefaults, which
|
||||||
|
# passes a `probe_unit`/`container`/`systemd_unit` attribute as $1 when set.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SERVICE="${1:-}"
|
SERVICE="${1:-}"
|
||||||
@@ -8,15 +20,31 @@ if [ -z "$SERVICE" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v systemctl >/dev/null 2>&1; then
|
ok() { echo "{\"health\":\"healthy\"}"; exit 0; }
|
||||||
echo '{"health":"unknown","signalKind":"process-check","evidence":"systemctl not found"}'
|
|
||||||
exit 0
|
# 1. exact systemd unit
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
STATE=$(systemctl is-active "$SERVICE" 2>/dev/null | head -1 || true)
|
||||||
|
[ "$STATE" = "active" ] && ok
|
||||||
|
|
||||||
|
# 2. prefix match: matrix -> matrix-synapse.service, house -> house.service, etc.
|
||||||
|
# --no-legend strips the header/footer so grep can see the unit rows; the
|
||||||
|
# pattern is a systemd unit glob.
|
||||||
|
if systemctl list-units --type=service --state=active --no-legend "$SERVICE*.service" 2>/dev/null \
|
||||||
|
| grep -q '\.service'; then
|
||||||
|
ok
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
STATE=$(systemctl is-active "$SERVICE" 2>/dev/null || echo "unknown")
|
# 3. a running docker container whose name contains the label.
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
if [ "$STATE" = "active" ]; then
|
if docker ps --filter "status=running" --filter "name=$SERVICE" --format '{{.Names}}' 2>/dev/null \
|
||||||
echo "{\"health\":\"healthy\"}"
|
| grep -q .; then
|
||||||
else
|
ok
|
||||||
echo "{\"health\":\"degraded\",\"signalKind\":\"$SERVICE\",\"evidence\":\"$SERVICE is $STATE\"}"
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
STATE=${STATE:-inactive}
|
||||||
|
STATE=${STATE//\"/}
|
||||||
|
SAFE_SERVICE=${SERVICE//\"/}
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"process\",\"evidence\":\"$SAFE_SERVICE is $STATE (no active unit/container matched)\"}"
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ type agent struct {
|
|||||||
apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals
|
apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals
|
||||||
apiToken string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it (no dev-open bypass)
|
apiToken string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it (no dev-open bypass)
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
// gate serializes turns per session (at most one in-flight turn per
|
||||||
|
// sessionID). See turngate.go and plan 2026-08-03 F1.
|
||||||
|
gate *turnGate
|
||||||
|
// queue holds operator messages that arrived while a turn was already
|
||||||
|
// running; they are auto-run when the gate frees (plan 2026-08-03 F2).
|
||||||
|
// See messagequeue.go.
|
||||||
|
queue *messageQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string) (*agent, error) {
|
func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string) (*agent, error) {
|
||||||
@@ -117,6 +124,8 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
|
|||||||
apiBase: apiBase,
|
apiBase: apiBase,
|
||||||
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
|
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
|
||||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
gate: newTurnGate(),
|
||||||
|
queue: newMessageQueue(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,10 +177,15 @@ type toolDef struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type agentEvent struct {
|
type agentEvent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Data any `json:"data,omitempty"`
|
Data any `json:"data,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Iteration int `json:"iteration,omitempty"`
|
Iteration int `json:"iteration,omitempty"`
|
||||||
|
// IsThinking marks text/text_delta events that carry the model's internal
|
||||||
|
// reasoning (text produced before tool calls in the same iteration), as
|
||||||
|
// distinct from the final response text. The frontend renders these as
|
||||||
|
// collapsible thinking blocks separated from the response.
|
||||||
|
IsThinking bool `json:"is_thinking,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) {
|
func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) {
|
||||||
@@ -349,6 +363,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),
|
||||||
@@ -359,7 +382,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
var msg openai.ChatCompletionMessage
|
var msg openai.ChatCompletionMessage
|
||||||
var acc openai.ChatCompletionAccumulator
|
var acc openai.ChatCompletionAccumulator
|
||||||
|
|
||||||
for attempt := 0; attempt <= maxLLMRetries; attempt++ {
|
// Capture token usage from this LLM response for activity logging.
|
||||||
|
// Previously always NULL — every agent_activity row had no token
|
||||||
|
// count. Now each tool call in this iteration gets the same total.
|
||||||
|
totalTokens := 0
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= maxLLMRetries; attempt++ {
|
||||||
acc = openai.ChatCompletionAccumulator{}
|
acc = openai.ChatCompletionAccumulator{}
|
||||||
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
|
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
|
||||||
for stream.Next() {
|
for stream.Next() {
|
||||||
@@ -391,14 +419,19 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
msg = acc.Choices[0].Message
|
msg = acc.Choices[0].Message
|
||||||
finishReason := acc.Choices[0].FinishReason
|
finishReason := acc.Choices[0].FinishReason
|
||||||
|
|
||||||
|
// Capture token usage from this iteration.
|
||||||
|
if acc.Usage.TotalTokens > 0 {
|
||||||
|
totalTokens = int(acc.Usage.TotalTokens)
|
||||||
|
}
|
||||||
|
|
||||||
if len(msg.ToolCalls) == 0 {
|
if len(msg.ToolCalls) == 0 {
|
||||||
if isRefusalOrEmpty(msg.Content) {
|
if isRefusalOrEmpty(msg.Content) {
|
||||||
if attempt < maxLLMRetries {
|
if attempt < maxLLMRetries {
|
||||||
slog.Warn("nomos: empty or refusal response, retrying",
|
slog.Warn("nomos: empty or refusal response, retrying",
|
||||||
"session", sessionID, "iter", i+1, "attempt", attempt+1,
|
"session", sessionID, "iter", i+1, "attempt", attempt+1,
|
||||||
"content_len", len(msg.Content), "finish_reason", finishReason)
|
"content_len", len(msg.Content), "finish_reason", finishReason)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// B.4: surface the real error context (finish_reason +
|
// B.4: surface the real error context (finish_reason +
|
||||||
// refusal text) instead of a generic "empty response" —
|
// refusal text) instead of a generic "empty response" —
|
||||||
// the operator can tell "content_filter — rephrase" from
|
// the operator can tell "content_filter — rephrase" from
|
||||||
@@ -447,7 +480,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
// led to each step. Emitting it lets the persist layer accumulate
|
// led to each step. Emitting it lets the persist layer accumulate
|
||||||
// per-iteration reasoning into the row's text field.
|
// per-iteration reasoning into the row's text field.
|
||||||
if strings.TrimSpace(msg.Content) != "" {
|
if strings.TrimSpace(msg.Content) != "" {
|
||||||
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID, IsThinking: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID)
|
slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID)
|
||||||
@@ -467,6 +500,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, totalTokens)
|
||||||
|
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},
|
||||||
@@ -506,7 +566,23 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
inputStr := string(inputJSON)
|
inputStr := string(inputJSON)
|
||||||
|
|
||||||
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, totalTokens)
|
||||||
|
|
||||||
|
// 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",
|
||||||
@@ -520,7 +596,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
}
|
}
|
||||||
|
|
||||||
resultJSON, _ := json.Marshal(result)
|
resultJSON, _ := json.Marshal(result)
|
||||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID)
|
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID, totalTokens)
|
||||||
|
|
||||||
// Link any execution this tool queued/started back to this
|
// Link any execution this tool queued/started back to this
|
||||||
// session, so the auto-continuation worker can feed its result
|
// session, so the auto-continuation worker can feed its result
|
||||||
@@ -551,6 +627,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.
|
||||||
|
|||||||
@@ -76,16 +76,20 @@ func (a *agent) processIdleSweep(ctx context.Context) {
|
|||||||
s := s
|
s := s
|
||||||
if s.CompletionNudges == 0 {
|
if s.CompletionNudges == 0 {
|
||||||
safego.Go("nomos:idle-nudge:"+s.ID, func() {
|
safego.Go("nomos:idle-nudge:"+s.ID, func() {
|
||||||
if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil {
|
note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+
|
||||||
slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err)
|
"If the goal is done (or can't be completed), call complete_task now with the outcome and a "+
|
||||||
return
|
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
|
||||||
|
s.Goal, idleTaskThreshold)
|
||||||
|
note = a.store.enrichResumeNote(ctx, s.ID, note)
|
||||||
|
// P1: only count the nudge if it actually delivered. resumeSession
|
||||||
|
// skips (returns false) when a turn is already active; bumping the
|
||||||
|
// counter anyway would make the next sweep auto-close a merely-busy
|
||||||
|
// session as "unanswered."
|
||||||
|
if a.resumeSession(ctx, s.ID, note) {
|
||||||
|
if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil {
|
||||||
|
slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+
|
|
||||||
"If the goal is done (or can't be completed), call complete_task now with the outcome and a "+
|
|
||||||
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
|
|
||||||
s.Goal, idleTaskThreshold)
|
|
||||||
note = a.store.enrichResumeNote(ctx, s.ID, note)
|
|
||||||
a.resumeSession(ctx, s.ID, note)
|
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -163,7 +167,9 @@ func (a *agent) processContinuations(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.store.markContinued(ctx, p.ExecID) // stamp first: a failure here must not cause a re-continue loop
|
// markContinued now happens inside continueSession, AFTER resumeSession
|
||||||
|
// actually runs (P0). Pre-marking here consumed the item even when
|
||||||
|
// resumeSession skipped on a busy session, losing the result.
|
||||||
safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) })
|
safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +185,18 @@ func (a *agent) processContinuations(ctx context.Context) {
|
|||||||
// something new to poll for.
|
// something new to poll for.
|
||||||
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
|
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
|
||||||
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
|
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
|
||||||
a.resumeSession(ctx, p.SessionID, buildContinuationNote(p))
|
// P0 (plans/2026-08-03-nomos-chat-changes-review.md): mark the execution
|
||||||
|
// continued ONLY after the turn actually ran. resumeSession skips (returns
|
||||||
|
// false) when another turn is already active for this session; marking
|
||||||
|
// before that — as the old code did — consumed the item (continued_at set,
|
||||||
|
// never re-queued by pendingContinuations) and silently lost the result.
|
||||||
|
// On a skip, leave it pending so the next worker tick retries once the
|
||||||
|
// active turn frees the permit.
|
||||||
|
if !a.resumeSession(ctx, p.SessionID, buildContinuationNote(p)) {
|
||||||
|
slog.Info("nomos: continuation deferred — a turn is active; will retry next tick", "session", p.SessionID, "execution", p.ExecID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.store.markContinued(ctx, p.ExecID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resumeSession re-invokes the agent for a session with a system-injected note —
|
// resumeSession re-invokes the agent for a session with a system-injected note —
|
||||||
@@ -187,7 +204,32 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
|
|||||||
// (handleAnswerQuestion) — persisting progress LIVE (a placeholder row updated
|
// (handleAnswerQuestion) — persisting progress LIVE (a placeholder row updated
|
||||||
// in place as each tool call lands) so the frontend poller sees each step,
|
// in place as each tool call lands) so the frontend poller sees each step,
|
||||||
// instead of total silence until the whole resume concludes.
|
// instead of total silence until the whole resume concludes.
|
||||||
func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
|
//
|
||||||
|
// F1 (plan 2026-08-03): this is the single entry point for EVERY background
|
||||||
|
// turn — the continuation worker, idle sweep, answer-question, /resume, and the
|
||||||
|
// empty-message reconnect all funnel through here. It acquires the session's
|
||||||
|
// turn permit non-blocking and SKIPS if a turn is already running. A duplicate
|
||||||
|
// resume while a turn (live or background) is active is exactly the
|
||||||
|
// interleaving that corrupted the activity panel and made tasks feel stuck.
|
||||||
|
//
|
||||||
|
// Returns whether the turn actually ran. Callers that mutate state before
|
||||||
|
// resuming (the continuation worker's markContinued, the idle sweep's nudge
|
||||||
|
// bump) MUST gate that mutation on a true return — otherwise a busy-skip leaves
|
||||||
|
// the state changed but the work undone (lost continuation / false auto-close).
|
||||||
|
// See plans/2026-08-03-nomos-chat-changes-review.md P0/P1.
|
||||||
|
func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool {
|
||||||
|
if !a.gate.acquire(sessionID, 0) {
|
||||||
|
slog.Info("nomos: turn already active, skipping background resume", "session", sessionID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Release the gate, then drain any operator message that was queued while
|
||||||
|
// this background turn ran (plan 2026-08-03 F2). Queued messages are run as
|
||||||
|
// real user turns server-side; resumeSession itself never enqueues.
|
||||||
|
defer func() {
|
||||||
|
a.gate.release(sessionID)
|
||||||
|
safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) })
|
||||||
|
}()
|
||||||
|
|
||||||
placeholder, _ := json.Marshal(map[string]any{
|
placeholder, _ := json.Marshal(map[string]any{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"text": "",
|
"text": "",
|
||||||
@@ -200,6 +242,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
|
|||||||
|
|
||||||
var toolCalls []map[string]any
|
var toolCalls []map[string]any
|
||||||
var finalText, errText string
|
var finalText, errText string
|
||||||
|
var finalThinking string
|
||||||
|
|
||||||
persist := func() {
|
persist := func() {
|
||||||
if msgID == uuid.Nil {
|
if msgID == uuid.Nil {
|
||||||
@@ -212,6 +255,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
|
|||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"text": text,
|
"text": text,
|
||||||
|
"thinking": finalThinking,
|
||||||
"tool_calls": toolCalls,
|
"tool_calls": toolCalls,
|
||||||
"auto": true, // marks this as an autonomous continuation, not an operator turn
|
"auto": true, // marks this as an autonomous continuation, not an operator turn
|
||||||
})
|
})
|
||||||
@@ -242,15 +286,19 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
|
|||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
select {
|
select {
|
||||||
case <-cctx.Done():
|
case <-cctx.Done():
|
||||||
return
|
return true // a turn ran on an earlier attempt; consume, don't re-loop
|
||||||
case <-time.After(time.Duration(2<<attempt) * time.Second): // 4s, 8s
|
case <-time.After(time.Duration(2<<attempt) * time.Second): // 4s, 8s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toolCalls, finalText, errText = nil, "", ""
|
toolCalls, finalText, errText = nil, "", ""
|
||||||
|
finalThinking = ""
|
||||||
// P3: accumulate per-iteration reasoning instead of overwriting
|
// P3: accumulate per-iteration reasoning instead of overwriting
|
||||||
// (same fix as main.go's chat handler). Without this, a resumed
|
// (same fix as main.go's chat handler). Without this, a resumed
|
||||||
// turn's intermediate thinking is lost on reload.
|
// turn's intermediate thinking is lost on reload.
|
||||||
|
// (same fix as main.go's chat handler). Without this, a resumed
|
||||||
|
// turn's intermediate thinking is lost on reload.
|
||||||
var textParts []string
|
var textParts []string
|
||||||
|
var thinkingParts []string
|
||||||
emit := func(ev agentEvent) {
|
emit := func(ev agentEvent) {
|
||||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||||
if m, ok := ev.Data.(map[string]any); ok {
|
if m, ok := ev.Data.(map[string]any); ok {
|
||||||
@@ -277,8 +325,13 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
|
|||||||
}
|
}
|
||||||
if ev.Type == "text" {
|
if ev.Type == "text" {
|
||||||
if t, ok := ev.Data.(string); ok && t != "" {
|
if t, ok := ev.Data.(string); ok && t != "" {
|
||||||
textParts = append(textParts, t)
|
if ev.IsThinking {
|
||||||
finalText = strings.Join(textParts, "\n\n")
|
thinkingParts = append(thinkingParts, t)
|
||||||
|
finalThinking = strings.Join(thinkingParts, "\n\n")
|
||||||
|
} else {
|
||||||
|
textParts = append(textParts, t)
|
||||||
|
finalText = strings.Join(textParts, "\n\n")
|
||||||
|
}
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,9 +367,10 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
|
|||||||
// No placeholder was inserted (rare), save directly.
|
// No placeholder was inserted (rare), save directly.
|
||||||
a.store.saveMessage(context.Background(), sessionID, "assistant", body)
|
a.store.saveMessage(context.Background(), sessionID, "assistant", body)
|
||||||
}
|
}
|
||||||
return // do not call persist() again — already persisted above
|
return true // do not call persist() again — already persisted above
|
||||||
}
|
}
|
||||||
persist() // final state — same row, updated one last time with the concluding text
|
persist() // final state — same row, updated one last time with the concluding text
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildContinuationNote frames the finished execution for the model: what
|
// buildContinuationNote frames the finished execution for the model: what
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
func TestExtractExecutionIDs(t *testing.T) {
|
func TestExtractExecutionIDs(t *testing.T) {
|
||||||
// Real tool-result phrasings that should yield an execution id.
|
// Real tool-result phrasings that should yield an execution id.
|
||||||
@@ -37,3 +42,37 @@ func TestExtractExecutionIDs(t *testing.T) {
|
|||||||
t.Errorf("expected de-dup to 1 id, got %v", ids)
|
t.Errorf("expected de-dup to 1 id, got %v", ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResumeSession_SkipsWhenBusy guards the P0 fix
|
||||||
|
// (plans/2026-08-03-nomos-chat-changes-review.md): resumeSession must skip —
|
||||||
|
// return false, body never executed — when a turn is already active for the
|
||||||
|
// session. continueSession relies on this so it only marks a continuation
|
||||||
|
// "continued" after a turn really ran (otherwise the result is lost: marked
|
||||||
|
// continued, never re-queued by pendingContinuations).
|
||||||
|
//
|
||||||
|
// A minimal agent with only a gate is enough: if the body ever ran, chatWith
|
||||||
|
// would dereference the nil provider and panic. Returning false cleanly proves
|
||||||
|
// the body was skipped.
|
||||||
|
func TestResumeSession_SkipsWhenBusy(t *testing.T) {
|
||||||
|
a := &agent{gate: newTurnGate()}
|
||||||
|
if !a.gate.acquire("sess", 0) {
|
||||||
|
t.Fatal("precondition: initial acquire should succeed on a free session")
|
||||||
|
}
|
||||||
|
ran := a.resumeSession(context.Background(), "sess", "note")
|
||||||
|
if ran {
|
||||||
|
t.Fatal("resumeSession must return false (skip) while a turn is active for the session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContinueSession_DefersWhenBusy guards the other half of P0: when the
|
||||||
|
// session is busy, continueSession defers (leaves the execution pending for the
|
||||||
|
// next worker tick) instead of running or marking it. It must return cleanly
|
||||||
|
// without reaching resumeSession's body (nil provider → panic) or markContinued.
|
||||||
|
func TestContinueSession_DefersWhenBusy(t *testing.T) {
|
||||||
|
a := &agent{gate: newTurnGate()}
|
||||||
|
if !a.gate.acquire("sess", 0) {
|
||||||
|
t.Fatal("precondition: initial acquire should succeed on a free session")
|
||||||
|
}
|
||||||
|
p := pendingContinuation{ExecID: uuid.New(), SessionID: "sess", Status: "completed"}
|
||||||
|
a.continueSession(context.Background(), p) // must not panic; must not run/mark
|
||||||
|
}
|
||||||
|
|||||||
@@ -319,8 +319,10 @@ func fetchTranscript(ctx context.Context, gateway, sid string) (transcript, sess
|
|||||||
}
|
}
|
||||||
// Fetch the plan (steps with generation numbers) for the
|
// Fetch the plan (steps with generation numbers) for the
|
||||||
// plan_generations assertion. A 404 or empty response is fine — a
|
// plan_generations assertion. A 404 or empty response is fine — a
|
||||||
// pure-DB Q&A with no propose_plan has no plan.
|
// pure-DB Q&A with no propose_plan has no plan. ?all=true returns every
|
||||||
if planResp, perr := http.Get(gateway + "/sessions/" + sid + "/plan"); perr == nil {
|
// generation so the assertion can count them (the default view returns
|
||||||
|
// only the current generation).
|
||||||
|
if planResp, perr := http.Get(gateway + "/sessions/" + sid + "/plan?all=true"); perr == nil {
|
||||||
if planResp.StatusCode == 200 {
|
if planResp.StatusCode == 200 {
|
||||||
pb, _ := io.ReadAll(planResp.Body)
|
pb, _ := io.ReadAll(planResp.Body)
|
||||||
_ = json.Unmarshal(pb, &t) // fills t.PlanSteps via "steps" field
|
_ = json.Unmarshal(pb, &t) // fills t.PlanSteps via "steps" field
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -165,6 +167,147 @@ func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
|
|||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runChatTurn is the shared core of an operator-initiated turn: insert an
|
||||||
|
// assistant placeholder, run a.chat with incremental persistence (so whatever
|
||||||
|
// happened before an abort is never lost), finalize the row, and derive a
|
||||||
|
// title. It is agnostic to the transport: `sink` receives every agent event
|
||||||
|
// for delivery (SSE for a live handleChat, a no-op for a queued turn that has
|
||||||
|
// no client attached — the frontend learns about those via the poller + the
|
||||||
|
// status-driven "working" signal). The caller MUST already hold the session's
|
||||||
|
// turn-gate permit.
|
||||||
|
func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string, sink func(agentEvent)) {
|
||||||
|
toolCalls := []map[string]any{}
|
||||||
|
// P3: accumulate per-iteration reasoning instead of overwriting with the
|
||||||
|
// final `text` event (see the original inline comment in handleChat).
|
||||||
|
var textParts []string
|
||||||
|
var thinkingParts []string
|
||||||
|
var finalText string
|
||||||
|
var finalThinking string
|
||||||
|
|
||||||
|
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
|
||||||
|
msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
|
||||||
|
}
|
||||||
|
persist := func() {
|
||||||
|
if msgID == uuid.Nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"role": "assistant",
|
||||||
|
"text": finalText,
|
||||||
|
"thinking": finalThinking,
|
||||||
|
"tool_calls": toolCalls,
|
||||||
|
})
|
||||||
|
a.store.updateMessage(pctx, msgID, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.chat(ctx, sessionID, message, func(ev agentEvent) {
|
||||||
|
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||||
|
if m, ok := ev.Data.(map[string]any); ok {
|
||||||
|
m["type"] = ev.Type
|
||||||
|
// One entry per tool call: tool_use creates it, tool_result
|
||||||
|
// merges the result into the same entry (matched by id).
|
||||||
|
id, _ := m["id"].(string)
|
||||||
|
if id != "" && ev.Type == "tool_result" {
|
||||||
|
for _, existing := range toolCalls {
|
||||||
|
if eID, _ := existing["id"].(string); eID == id {
|
||||||
|
for k, v := range m {
|
||||||
|
existing[k] = v
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toolCalls = append(toolCalls, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
persist() // live: survives even if the client disconnects right after
|
||||||
|
}
|
||||||
|
if ev.Type == "text" {
|
||||||
|
if t, ok := ev.Data.(string); ok && t != "" {
|
||||||
|
if ev.IsThinking {
|
||||||
|
thinkingParts = append(thinkingParts, t)
|
||||||
|
finalThinking = strings.Join(thinkingParts, "\n\n")
|
||||||
|
} else {
|
||||||
|
textParts = append(textParts, t)
|
||||||
|
finalText = strings.Join(textParts, "\n\n")
|
||||||
|
}
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sink(ev)
|
||||||
|
})
|
||||||
|
|
||||||
|
// B.6: if the turn ended with no text and no tool calls (the model
|
||||||
|
// empty-response'd and all retries failed), delete the placeholder row
|
||||||
|
// instead of persisting an empty bubble.
|
||||||
|
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
|
||||||
|
a.store.deleteMessage(pctx, msgID)
|
||||||
|
} else {
|
||||||
|
persist() // final state — same row, updated one last time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title: prefer the goal once set; else the first assistant answer.
|
||||||
|
if finalText != "" && sessionID != "ephemeral" {
|
||||||
|
var goalTitle string
|
||||||
|
if sess, gerr := a.store.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
|
||||||
|
goalTitle = truncate(sess.Goal, 120)
|
||||||
|
}
|
||||||
|
title := goalTitle
|
||||||
|
if title == "" {
|
||||||
|
title = truncate(finalText, 80)
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
a.store.updateSessionTitle(pctx, sessionID, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainAcquireWait is how long drainQueued blocks for a busy gate before
|
||||||
|
// re-queuing and deferring to the holder's own release-drain. A package var so
|
||||||
|
// tests can shorten it; in production it just needs to outlast the brief
|
||||||
|
// release→drain handoff window.
|
||||||
|
var drainAcquireWait = 5 * time.Second
|
||||||
|
|
||||||
|
// drainQueued runs every queued operator message for a session as its own turn,
|
||||||
|
// one at a time, under the turn gate. Called (in a goroutine) whenever a turn
|
||||||
|
// releases the gate — from handleChat (live) and resumeSession (background) —
|
||||||
|
// so a message queued while the agent was busy is acted on as soon as it's
|
||||||
|
// free, without the operator re-sending. See messagequeue.go (plan 2026-08-03
|
||||||
|
// F2).
|
||||||
|
//
|
||||||
|
// Each queued turn is persisted incrementally and has no SSE client (the
|
||||||
|
// browser detached after receiving the `queued` event); the frontend sees the
|
||||||
|
// result via the 3s poller and the status-driven "working" indicator.
|
||||||
|
func (a *agent) drainQueued(ctx context.Context, sessionID string) {
|
||||||
|
for {
|
||||||
|
msg, ok := a.queue.dequeue(sessionID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Block briefly for the gate. If a live turn grabbed it first, put the
|
||||||
|
// message back — that turn's release will drain it again. Never stack.
|
||||||
|
if !a.gate.acquire(sessionID, drainAcquireWait) {
|
||||||
|
a.queue.requeueFront(sessionID, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("nomos: running queued operator message", "session", sessionID)
|
||||||
|
pctx := context.Background()
|
||||||
|
// Run the turn inside a per-iteration closure so the gate release is
|
||||||
|
// deferred to the end of THIS turn (and runs even if runChatTurn
|
||||||
|
// panics — safego recovers the panic at the goroutine boundary, so a
|
||||||
|
// non-deferred release would be skipped and the session's permit held
|
||||||
|
// forever, deadlocking all future turns). A bare `defer release` in
|
||||||
|
// the loop would be wrong too: Go defers run at function exit, not
|
||||||
|
// iteration exit, so the gate would stay held across iterations.
|
||||||
|
func() {
|
||||||
|
defer a.gate.release(sessionID)
|
||||||
|
a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", 405)
|
http.Error(w, "method not allowed", 405)
|
||||||
@@ -184,12 +327,22 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty message with an existing session = reconnect/resume. The
|
// Empty message with an existing session = reconnect/resume. This path is
|
||||||
// frontend sends this after a dropped SSE stream to re-establish the
|
// defensive now — the frontend (post F2) recovers a dropped SSE via the
|
||||||
// connection and catch up on any auto-continuation work that happened
|
// poller + terminal task.status clearing, and no longer POSTs empty
|
||||||
// while disconnected. Route into resumeSession so the agent sees a
|
// messages. If a client ever does, route into resumeSession so the agent
|
||||||
// system note and reports current state.
|
// reports current state — but SKIP a terminal session (done/failed/
|
||||||
|
// abandoned): there's nothing to resume, and running a "report state"
|
||||||
|
// turn there is just a spare turn the operator never asked for (P2.1).
|
||||||
if req.Message == "" && req.SessionID != "" {
|
if req.Message == "" && req.SessionID != "" {
|
||||||
|
if sess, err := st.getSession(context.Background(), req.SessionID); err == nil {
|
||||||
|
switch sess.Status {
|
||||||
|
case "done", "failed", "abandoned":
|
||||||
|
slog.Info("nomos: reconnect skipped — session already terminal", "session", req.SessionID, "status", sess.Status)
|
||||||
|
w.WriteHeader(202)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
slog.Info("nomos: reconnect", "session", req.SessionID)
|
slog.Info("nomos: reconnect", "session", req.SessionID)
|
||||||
safego.Go("nomos:reconnect:"+req.SessionID, func() {
|
safego.Go("nomos:reconnect:"+req.SessionID, func() {
|
||||||
base := "[System: the operator's connection was re-established. The task may have progressed in the background.]"
|
base := "[System: the operator's connection was re-established. The task may have progressed in the background.]"
|
||||||
@@ -198,7 +351,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
})
|
})
|
||||||
// Return 202 so the frontend doesn't try to consume an SSE stream
|
// Return 202 so the frontend doesn't try to consume an SSE stream
|
||||||
// from this POST — resumeSession writes to the DB directly and
|
// from this POST — resumeSession writes to the DB directly and
|
||||||
// the poller (already running from handleDisconnect) picks it up.
|
// the poller picks it up.
|
||||||
w.WriteHeader(202)
|
w.WriteHeader(202)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -212,8 +365,20 @@ 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)
|
||||||
|
|
||||||
|
// All writes to w (events + the keepalive comment below) go through one
|
||||||
|
// mutex: http.ResponseWriter is NOT safe for concurrent use, and the
|
||||||
|
// keepalive ticker runs alongside the turn's event sink (plan 2026-08-03
|
||||||
|
// F3). Without this, interleaved writes corrupt the SSE stream.
|
||||||
|
var writeMu sync.Mutex
|
||||||
|
writeEvent := func(ev agentEvent) {
|
||||||
|
writeMu.Lock()
|
||||||
|
defer writeMu.Unlock()
|
||||||
|
sseEvent(w, flusher, ev)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
sessionID := req.SessionID
|
sessionID := req.SessionID
|
||||||
|
|
||||||
@@ -265,98 +430,65 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
st.answerQuestion(pctx, sessionID, qid, req.Message)
|
st.answerQuestion(pctx, sessionID, qid, req.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
|
writeEvent(agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
|
||||||
|
|
||||||
toolCalls := []map[string]any{}
|
// F1/F2 (plan 2026-08-03): serialize turns per session. The user message is
|
||||||
// P3: accumulate per-iteration reasoning instead of overwriting with
|
// already persisted above, so it is never lost. Wait briefly for a finishing
|
||||||
// the final `text` event. The agent loop emits a `text` event for each
|
// background turn; if one is still running after that, QUEUE this message
|
||||||
// LLM iteration that produced text (intermediate reasoning before tool
|
// (don't reject it) and tell the client so it shows a "queued" state. The
|
||||||
// calls + the final answer). Without accumulation, only the last `text`
|
// in-flight turn's release drains the queue (drainQueued) and runs it as a
|
||||||
// survives in the persisted row — a reload shows the final summary but
|
// real turn server-side. This never stacks concurrent turns — the gate still
|
||||||
// not the thinking that led to each tool call.
|
// guarantees one in-flight turn per session.
|
||||||
var textParts []string
|
const turnWait = 5 * time.Second
|
||||||
var finalText string
|
if !a.gate.acquire(sessionID, turnWait) {
|
||||||
|
a.queue.enqueue(sessionID, req.Message)
|
||||||
// Incremental persistence, mirroring resumeSession's existing
|
slog.Info("nomos: turn already active, queued operator message", "session", sessionID)
|
||||||
// placeholder+update pattern (continue.go): insert a placeholder now,
|
writeEvent(agentEvent{Type: "queued", Data: sessionID, SessionID: sessionID})
|
||||||
// update the SAME row after every tool call, so whatever happened before
|
writeEvent(agentEvent{Type: "done", Data: map[string]any{
|
||||||
// an abort is never lost — only what hadn't happened yet is.
|
"session_id": sessionID,
|
||||||
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
|
"queued": true,
|
||||||
msgID, err := st.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
|
}, SessionID: sessionID})
|
||||||
if err != nil {
|
return
|
||||||
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
|
|
||||||
}
|
|
||||||
persist := func() {
|
|
||||||
if msgID == uuid.Nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
|
||||||
"role": "assistant",
|
|
||||||
"text": finalText,
|
|
||||||
"tool_calls": toolCalls,
|
|
||||||
})
|
|
||||||
st.updateMessage(pctx, msgID, body)
|
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
a.gate.release(sessionID)
|
||||||
|
// Run any message that was queued while this turn held the gate. In a
|
||||||
|
// goroutine so the HTTP response finishes without waiting on the next
|
||||||
|
// turn; the queued turn has no SSE client of its own.
|
||||||
|
safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) })
|
||||||
|
}()
|
||||||
|
|
||||||
a.chat(ctx, sessionID, req.Message, func(ev agentEvent) {
|
// F3 (plan 2026-08-03): keep the SSE alive during long turns. A turn can
|
||||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
// run for many minutes (provisioning chains, deep research); the model
|
||||||
if m, ok := ev.Data.(map[string]any); ok {
|
// often takes 20-40s between tool iterations, and with nothing flushed in
|
||||||
m["type"] = ev.Type
|
// that gap a proxy/browser idle timeout silently closes the stream. The
|
||||||
// One entry per tool call: tool_use creates it, tool_result
|
// client then sees streaming=false while the server keeps working — the
|
||||||
// merges the result into the same entry (matched by id).
|
// "I can't tell it's working" desync. An SSE comment line (":keepalive") is
|
||||||
// Before this fix, both events appended separate entries,
|
// ignored by EventSource but resets idle timers.
|
||||||
// doubling every tool call in the persisted transcript
|
keepDone := make(chan struct{})
|
||||||
// (confirmed pre-existing in d9cdcee1, v0.3.x era).
|
go func() {
|
||||||
id, _ := m["id"].(string)
|
t := time.NewTicker(12 * time.Second)
|
||||||
if id != "" && ev.Type == "tool_result" {
|
defer t.Stop()
|
||||||
for _, existing := range toolCalls {
|
for {
|
||||||
if eID, _ := existing["id"].(string); eID == id {
|
select {
|
||||||
for k, v := range m {
|
case <-keepDone:
|
||||||
existing[k] = v
|
return
|
||||||
}
|
case <-t.C:
|
||||||
break
|
writeMu.Lock()
|
||||||
}
|
fmt.Fprintf(w, ":keepalive\n\n")
|
||||||
}
|
flusher.Flush()
|
||||||
} else {
|
writeMu.Unlock()
|
||||||
toolCalls = append(toolCalls, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
persist() // live: survives even if the client disconnects right after
|
|
||||||
}
|
|
||||||
if ev.Type == "text" {
|
|
||||||
// P3: accumulate. Each `text` event is one iteration's reasoning
|
|
||||||
// (or the final answer). Join with newlines so the persisted row
|
|
||||||
// reads as the full transcript of what the agent said, not just
|
|
||||||
// the last thing.
|
|
||||||
if t, ok := ev.Data.(string); ok && t != "" {
|
|
||||||
textParts = append(textParts, t)
|
|
||||||
finalText = strings.Join(textParts, "\n\n")
|
|
||||||
persist()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sseEvent(w, flusher, ev)
|
}()
|
||||||
|
// Defer the close (not a statement after runChatTurn) so the goroutine
|
||||||
|
// exits even if runChatTurn panics — net/http recovers handler panics, so
|
||||||
|
// a non-deferred close would be skipped and the ticker would keep writing
|
||||||
|
// to a dead ResponseWriter forever.
|
||||||
|
defer close(keepDone)
|
||||||
|
a.runChatTurn(pctx, ctx, sessionID, req.Message, func(ev agentEvent) {
|
||||||
|
writeEvent(ev)
|
||||||
})
|
})
|
||||||
|
|
||||||
// B.6: if the turn ended with no text and no tool calls (the model
|
|
||||||
// empty-response'd and all retries failed), delete the placeholder row
|
|
||||||
// instead of persisting an empty bubble. The error event was already
|
|
||||||
// streamed to the frontend via the 'done with error=true' event, so the
|
|
||||||
// operator sees the error inline — an empty assistant bubble in the
|
|
||||||
// transcript adds nothing and looks like the agent is broken.
|
|
||||||
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
|
|
||||||
st.deleteMessage(pctx, msgID)
|
|
||||||
} else {
|
|
||||||
persist() // final state — same row, updated one last time with the concluding text
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a meaningful title from the assistant's first answer
|
|
||||||
// instead of reusing the raw user message for every session.
|
|
||||||
if finalText != "" && sessionID != "ephemeral" {
|
|
||||||
title := truncate(finalText, 80)
|
|
||||||
if title != "" {
|
|
||||||
st.updateSessionTitle(pctx, sessionID, title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
|
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
|
||||||
@@ -370,13 +502,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,10 +591,16 @@ 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":
|
||||||
steps, err := st.getPlanSteps(r.Context(), id)
|
all := r.URL.Query().Has("all") && r.URL.Query().Get("all") != "0" && r.URL.Query().Get("all") != "false"
|
||||||
|
steps, err := st.getPlanSteps(r.Context(), id, all)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
@@ -436,6 +617,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 +638,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)
|
||||||
@@ -605,7 +818,7 @@ func newMCPClient(baseURL, token string) (*mcpClient, error) {
|
|||||||
c := &mcpClient{
|
c := &mcpClient{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
token: token,
|
token: token,
|
||||||
http: &http.Client{Timeout: 30 * time.Second},
|
http: &http.Client{Timeout: 120 * time.Second},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.doRequest("initialize", map[string]any{
|
resp, err := c.doRequest("initialize", map[string]any{
|
||||||
|
|||||||
82
cmd/nomos/messagequeue.go
Normal file
82
cmd/nomos/messagequeue.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxQueuedPerSession caps a session's queue. A held turn plus unbounded
|
||||||
|
// enqueues would grow memory without limit; an operator nudging a long
|
||||||
|
// autonomous turn realistically queues only a handful, so a generous cap is
|
||||||
|
// pure insurance. Overflow drops the newest enqueue and logs (the message is
|
||||||
|
// already persisted in the DB by handleChat before enqueue, so it isn't lost
|
||||||
|
// from the transcript — it just won't auto-run).
|
||||||
|
const maxQueuedPerSession = 20
|
||||||
|
|
||||||
|
// messageQueue holds operator messages that arrived while a turn was already
|
||||||
|
// running for a session. Plan 2026-08-03 (F2): instead of rejecting the
|
||||||
|
// operator's message with "Nomos is still finishing a previous step… send it
|
||||||
|
// again", the message is queued and auto-run when the in-flight turn releases
|
||||||
|
// the session's turn-gate permit.
|
||||||
|
//
|
||||||
|
// The queue only schedules WHEN a turn runs, not WHETHER the message is stored
|
||||||
|
// — handleChat persists the user message before acquiring the gate, so a queued
|
||||||
|
// message is already in the transcript; this just makes sure a turn eventually
|
||||||
|
// acts on it.
|
||||||
|
//
|
||||||
|
// Draining is strictly one-at-a-time under the turn gate (see drainQueued in
|
||||||
|
// main.go), so this cannot stack concurrent turns — the exact hazard the gate
|
||||||
|
// itself exists to prevent. Background resumeSession callers never touch this
|
||||||
|
// queue; they keep their non-blocking skip.
|
||||||
|
type messageQueue struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
queue map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMessageQueue() *messageQueue {
|
||||||
|
return &messageQueue{queue: map[string][]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue appends a message to the back of the session's FIFO. Returns false
|
||||||
|
// (and logs) if the session is already at maxQueuedPerSession — the caller's
|
||||||
|
// message is already persisted in the DB, so this only skips auto-running it.
|
||||||
|
func (q *messageQueue) enqueue(sessionID, msg string) bool {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
if len(q.queue[sessionID]) >= maxQueuedPerSession {
|
||||||
|
slog.Warn("nomos: message queue full; dropping auto-run for operator message", "session", sessionID, "cap", maxQueuedPerSession)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
q.queue[sessionID] = append(q.queue[sessionID], msg)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// dequeue pops the next message from the front of the session's FIFO. Returns
|
||||||
|
// ok=false when empty.
|
||||||
|
func (q *messageQueue) dequeue(sessionID string) (string, bool) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
xs := q.queue[sessionID]
|
||||||
|
if len(xs) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
m := xs[0]
|
||||||
|
q.queue[sessionID] = xs[1:]
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// requeueFront pushes a message back to the front — used when a drainer popped
|
||||||
|
// a message but lost the race for the gate to a live turn; that turn's own
|
||||||
|
// release will drain it again.
|
||||||
|
func (q *messageQueue) requeueFront(sessionID, msg string) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
q.queue[sessionID] = append([]string{msg}, q.queue[sessionID]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// peek reports the queued depth for a session (test/diagnostic helper).
|
||||||
|
func (q *messageQueue) peek(sessionID string) int {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return len(q.queue[sessionID])
|
||||||
|
}
|
||||||
142
cmd/nomos/messagequeue_test.go
Normal file
142
cmd/nomos/messagequeue_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMessageQueue_FIFO(t *testing.T) {
|
||||||
|
q := newMessageQueue()
|
||||||
|
q.enqueue("s", "first")
|
||||||
|
q.enqueue("s", "second")
|
||||||
|
q.enqueue("s", "third")
|
||||||
|
|
||||||
|
want := []string{"first", "second", "third"}
|
||||||
|
for _, w := range want {
|
||||||
|
got, ok := q.dequeue("s")
|
||||||
|
if !ok || got != w {
|
||||||
|
t.Fatalf("dequeue = %q,%v want %q,true", got, ok, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := q.dequeue("s"); ok {
|
||||||
|
t.Fatal("dequeue on drained queue should return ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageQueue_RequeueFront(t *testing.T) {
|
||||||
|
q := newMessageQueue()
|
||||||
|
q.enqueue("s", "a")
|
||||||
|
q.enqueue("s", "b")
|
||||||
|
// Pop "a", then push it back to the front; "a" must come out before "b".
|
||||||
|
a, _ := q.dequeue("s")
|
||||||
|
q.requeueFront("s", a)
|
||||||
|
got, _ := q.dequeue("s")
|
||||||
|
if got != "a" {
|
||||||
|
t.Fatalf("after requeueFront, dequeue = %q want %q", got, "a")
|
||||||
|
}
|
||||||
|
got2, _ := q.dequeue("s")
|
||||||
|
if got2 != "b" {
|
||||||
|
t.Fatalf("next dequeue = %q want %q", got2, "b")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageQueue_IsolatedPerSession(t *testing.T) {
|
||||||
|
q := newMessageQueue()
|
||||||
|
q.enqueue("s1", "one")
|
||||||
|
q.enqueue("s2", "two")
|
||||||
|
if got, _ := q.dequeue("s1"); got != "one" {
|
||||||
|
t.Fatalf("s1 = %q want one", got)
|
||||||
|
}
|
||||||
|
if got, _ := q.dequeue("s2"); got != "two" {
|
||||||
|
t.Fatalf("s2 = %q want two", got)
|
||||||
|
}
|
||||||
|
if q.peek("s1") != 0 || q.peek("s2") != 0 {
|
||||||
|
t.Fatal("both sessions should be drained")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageQueue_Concurrent(t *testing.T) {
|
||||||
|
q := newMessageQueue()
|
||||||
|
const n = maxQueuedPerSession // stay under the cap so every enqueue lands
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
q.enqueue("s", "m")
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if q.peek("s") != n {
|
||||||
|
t.Fatalf("peek = %d want %d (all enqueues must be counted)", q.peek("s"), n)
|
||||||
|
}
|
||||||
|
seen := 0
|
||||||
|
for {
|
||||||
|
if _, ok := q.dequeue("s"); !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seen++
|
||||||
|
}
|
||||||
|
if seen != n {
|
||||||
|
t.Fatalf("drained %d want %d", seen, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageQueue_CapsOverflow(t *testing.T) {
|
||||||
|
q := newMessageQueue()
|
||||||
|
for i := 0; i < maxQueuedPerSession; i++ {
|
||||||
|
if !q.enqueue("s", "m") {
|
||||||
|
t.Fatalf("enqueue #%d within cap should succeed", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.enqueue("s", "overflow") {
|
||||||
|
t.Fatal("enqueue past the cap should return false (dropped)")
|
||||||
|
}
|
||||||
|
if got := q.peek("s"); got != maxQueuedPerSession {
|
||||||
|
t.Fatalf("peek = %d want %d (overflow must not append)", got, maxQueuedPerSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainQueued on an empty queue must be a no-op: it returns immediately and
|
||||||
|
// never touches the gate (so the session stays free for the next turn).
|
||||||
|
func TestDrainQueued_NoOpOnEmpty(t *testing.T) {
|
||||||
|
a := &agent{gate: newTurnGate(), queue: newMessageQueue()}
|
||||||
|
a.drainQueued(context.Background(), "s")
|
||||||
|
if !a.gate.acquire("s", 0) {
|
||||||
|
t.Fatal("gate should be free after a no-op drain (drain must not hold it)")
|
||||||
|
}
|
||||||
|
a.gate.release("s")
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a queued message but the gate held by another turn, drainQueued must
|
||||||
|
// re-queue the message and return WITHOUT running a turn (no store/provider → a
|
||||||
|
// real run would panic). This is the "never stack" property: a busy gate
|
||||||
|
// defers to the holder's own release-drain.
|
||||||
|
func TestDrainQueued_RequeuesWhenBusy(t *testing.T) {
|
||||||
|
prev := drainAcquireWait
|
||||||
|
drainAcquireWait = 10 * time.Millisecond
|
||||||
|
t.Cleanup(func() { drainAcquireWait = prev })
|
||||||
|
|
||||||
|
a := &agent{gate: newTurnGate(), queue: newMessageQueue()}
|
||||||
|
if !a.gate.acquire("s", 0) {
|
||||||
|
t.Fatal("precondition: hold the gate")
|
||||||
|
}
|
||||||
|
a.queue.enqueue("s", "queued-msg")
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
a.drainQueued(context.Background(), "s") // must not panic; must requeue
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("drainQueued did not return promptly while the gate was busy")
|
||||||
|
}
|
||||||
|
if got := a.queue.peek("s"); got != 1 {
|
||||||
|
t.Fatalf("message should be re-queued while busy; peek = %d want 1", got)
|
||||||
|
}
|
||||||
|
a.gate.release("s")
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -192,7 +192,7 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark step 1 as started.
|
// Mark step 1 as started.
|
||||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
|
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||||
t.Fatalf("updatePlanStep: %v", err)
|
t.Fatalf("updatePlanStep: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The original step 1 must be untouched — not erased, not appended to.
|
// The original step 1 must be untouched — not erased, not appended to.
|
||||||
steps, err := s.getPlanSteps(ctx, sess.ID)
|
steps, err := s.getPlanSteps(ctx, sess.ID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getPlanSteps: %v", err)
|
t.Fatalf("getPlanSteps: %v", err)
|
||||||
}
|
}
|
||||||
@@ -217,7 +217,8 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Third call BEFORE anything runs on a fresh session: every step is
|
// Third call BEFORE anything runs on a fresh session: every step is
|
||||||
// still pending, so this must REPLACE, not refuse.
|
// still pending, so this must REPLACE (mark the prior plan `replaced`),
|
||||||
|
// not refuse. The new plan becomes generation 2.
|
||||||
sess2, err := s.createSession(ctx, "plan replace test")
|
sess2, err := s.createSession(ctx, "plan replace test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("createSession: %v", err)
|
t.Fatalf("createSession: %v", err)
|
||||||
@@ -228,15 +229,146 @@ func TestProposePlan_RefuseInFlight(t *testing.T) {
|
|||||||
if _, err := s.proposePlan(ctx, sess2.ID, []planStepInput{{Title: "Revised"}}); err != nil {
|
if _, err := s.proposePlan(ctx, sess2.ID, []planStepInput{{Title: "Revised"}}); err != nil {
|
||||||
t.Fatalf("proposePlan (revise before execution): %v", err)
|
t.Fatalf("proposePlan (revise before execution): %v", err)
|
||||||
}
|
}
|
||||||
revisedSteps, err := s.getPlanSteps(ctx, sess2.ID)
|
// Default (current generation) view: only the revised step.
|
||||||
|
revisedSteps, err := s.getPlanSteps(ctx, sess2.ID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getPlanSteps: %v", err)
|
t.Fatalf("getPlanSteps: %v", err)
|
||||||
}
|
}
|
||||||
if len(revisedSteps) != 1 || revisedSteps[0].Title != "Revised" {
|
if len(revisedSteps) != 1 || revisedSteps[0].Title != "Revised" {
|
||||||
t.Fatalf("got %+v, want a single 'Revised' step (pre-execution revise must replace, not refuse)", revisedSteps)
|
t.Fatalf("got %+v, want a single 'Revised' step (current-generation view)", revisedSteps)
|
||||||
}
|
}
|
||||||
if revisedSteps[0].Generation != 1 {
|
if revisedSteps[0].Seq != 1 {
|
||||||
t.Fatalf("revised step generation = %d, want 1 (fresh-start after DELETE resets generation)", revisedSteps[0].Generation)
|
t.Fatalf("revised step seq = %d, want 1 (seq is generation-relative, resets to 1..N)", revisedSteps[0].Seq)
|
||||||
|
}
|
||||||
|
if revisedSteps[0].Generation != 2 {
|
||||||
|
t.Fatalf("revised step generation = %d, want 2 (prior pending plan is replaced, not deleted, so the counter increments)", revisedSteps[0].Generation)
|
||||||
|
}
|
||||||
|
// all=true audit view: both generations, the original marked `replaced`.
|
||||||
|
allSteps, err := s.getPlanSteps(ctx, sess2.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPlanSteps(all): %v", err)
|
||||||
|
}
|
||||||
|
if len(allSteps) != 2 {
|
||||||
|
t.Fatalf("all=true got %d steps, want 2 (Original replaced gen1 + Revised gen2)", len(allSteps))
|
||||||
|
}
|
||||||
|
if allSteps[0].Title != "Original" || allSteps[0].Status != "replaced" || allSteps[0].Generation != 1 {
|
||||||
|
t.Errorf("gen1 step = %+v, want Original/replaced/gen1", allSteps[0])
|
||||||
|
}
|
||||||
|
if allSteps[1].Title != "Revised" || allSteps[1].Generation != 2 || allSteps[1].Seq != 1 {
|
||||||
|
t.Errorf("gen2 step = %+v, want Revised/gen2/seq1", allSteps[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdatePlanStep_GenerationRelative is the P0.1 regression proof: after a
|
||||||
|
// re-plan, update_plan_step(seq=N) — using the 1-based number the model
|
||||||
|
// naturally carries — must address the CURRENT generation and never resurrect
|
||||||
|
// a superseded generation's `replaced` row. Before the fix, seq was globally
|
||||||
|
// increasing across generations, so seq=1 after a re-plan flipped the gen-1
|
||||||
|
// `replaced` step back to `running`/`done` while the real gen-2 work went
|
||||||
|
// unrecorded.
|
||||||
|
func TestUpdatePlanStep_GenerationRelative(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sess, err := s.createSession(ctx, "gen-relative seq test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createSession: %v", err)
|
||||||
|
}
|
||||||
|
// Generation 1: two steps.
|
||||||
|
if _, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
|
||||||
|
t.Fatalf("proposePlan #1: %v", err)
|
||||||
|
}
|
||||||
|
// Re-plan: setGoal marks the gen-1 plan `replaced`, proposePlan starts gen 2.
|
||||||
|
if err := s.setGoal(ctx, sess.ID, "follow-up sub-task"); err != nil {
|
||||||
|
t.Fatalf("setGoal: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "C"}, {Title: "D"}}); err != nil {
|
||||||
|
t.Fatalf("proposePlan #2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The model addresses the new plan with 1-based seq. seq=1 must hit
|
||||||
|
// gen-2 "C", leaving gen-1 "A" (replaced) untouched.
|
||||||
|
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||||
|
t.Fatalf("updatePlanStep(seq=1, running): %v", err)
|
||||||
|
}
|
||||||
|
if err := s.updatePlanStep(ctx, sess.ID, 1, "done", "", ""); err != nil {
|
||||||
|
t.Fatalf("updatePlanStep(seq=1, done): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := s.getPlanSteps(ctx, sess.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPlanSteps(all): %v", err)
|
||||||
|
}
|
||||||
|
byTitle := map[string]planStep{}
|
||||||
|
for _, st := range all {
|
||||||
|
byTitle[st.Title] = st
|
||||||
|
}
|
||||||
|
// gen-1 steps stay `replaced` — NOT resurrected to running/done.
|
||||||
|
if byTitle["A"].Status != "replaced" || byTitle["A"].Generation != 1 {
|
||||||
|
t.Errorf("A = %+v, want replaced/gen1 (a superseded row must never be touched)", byTitle["A"])
|
||||||
|
}
|
||||||
|
if byTitle["B"].Status != "replaced" || byTitle["B"].Generation != 1 {
|
||||||
|
t.Errorf("B = %+v, want replaced/gen1", byTitle["B"])
|
||||||
|
}
|
||||||
|
// gen-2 seq=1 advanced; seq=2 untouched.
|
||||||
|
if byTitle["C"].Status != "done" || byTitle["C"].Generation != 2 || byTitle["C"].Seq != 1 {
|
||||||
|
t.Errorf("C = %+v, want done/gen2/seq1 (the 1-based update must address the current generation)", byTitle["C"])
|
||||||
|
}
|
||||||
|
if byTitle["D"].Status != "pending" || byTitle["D"].Seq != 2 {
|
||||||
|
t.Errorf("D = %+v, want pending/seq2", byTitle["D"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out-of-range seq must be refused (no current-gen step there).
|
||||||
|
if err := s.updatePlanStep(ctx, sess.ID, 99, "running", "", ""); !errors.Is(err, errPlanStepNotFound) {
|
||||||
|
t.Fatalf("updatePlanStep(seq=99) err = %v, want errPlanStepNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteTask_AutoCloseEmitsEvents is the P1.1 regression proof:
|
||||||
|
// completeTask's bulk auto-close of in-flight steps must emit one
|
||||||
|
// plan.step.finished event per closed step (so the live panel converges
|
||||||
|
// instead of freezing on "running" after the task completes) and must stamp
|
||||||
|
// started_at so no closed step is left un-timestamped (P0.1 fix 5).
|
||||||
|
func TestCompleteTask_AutoCloseEmitsEvents(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sess, err := s.createSession(ctx, "auto-close events test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createSession: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "A"}, {Title: "B"}}); err != nil {
|
||||||
|
t.Fatalf("proposePlan: %v", err)
|
||||||
|
}
|
||||||
|
// A is running, B still pending at completion time.
|
||||||
|
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil {
|
||||||
|
t.Fatalf("updatePlanStep(1, running): %v", err)
|
||||||
|
}
|
||||||
|
if err := s.completeTask(ctx, sess.ID, "success", "done"); err != nil {
|
||||||
|
t.Fatalf("completeTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every auto-closed step should now carry both a started_at and a
|
||||||
|
// finished_at (no NULL-started `done` step).
|
||||||
|
steps, err := s.getPlanSteps(ctx, sess.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPlanSteps: %v", err)
|
||||||
|
}
|
||||||
|
for _, st := range steps {
|
||||||
|
if st.Status == "done" && st.StartedAt == nil {
|
||||||
|
t.Errorf("step %q done but started_at is NULL (P0.1 fix 5: stamp it)", st.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly two plan.step.finished events — one per closed step (A and B).
|
||||||
|
var finished int
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM events WHERE type = 'plan.step.finished' AND correlation_id = $1`,
|
||||||
|
sess.ID).Scan(&finished); err != nil {
|
||||||
|
t.Fatalf("count events: %v", err)
|
||||||
|
}
|
||||||
|
if finished != 2 {
|
||||||
|
t.Fatalf("plan.step.finished events = %d, want 2 (one per auto-closed step)", finished)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +397,7 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
|||||||
|
|
||||||
// A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback.
|
// A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback.
|
||||||
agentID := uuid.New()
|
agentID := uuid.New()
|
||||||
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1")
|
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1", 0)
|
||||||
if !s.hadDiscovery(ctx, sess.ID) {
|
if !s.hadDiscovery(ctx, sess.ID) {
|
||||||
t.Fatal("hadDiscovery = false after a successful run call, want true")
|
t.Fatal("hadDiscovery = false after a successful run call, want true")
|
||||||
}
|
}
|
||||||
@@ -278,7 +410,7 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("createSession: %v", err)
|
t.Fatalf("createSession: %v", err)
|
||||||
}
|
}
|
||||||
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2")
|
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2", 0)
|
||||||
if s.hadDiscovery(ctx, sess2.ID) {
|
if s.hadDiscovery(ctx, sess2.ID) {
|
||||||
t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)")
|
t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)")
|
||||||
}
|
}
|
||||||
@@ -288,7 +420,7 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("createSession: %v", err)
|
t.Fatalf("createSession: %v", err)
|
||||||
}
|
}
|
||||||
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3")
|
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3", 0)
|
||||||
if s.hadDiscovery(ctx, sess3.ID) {
|
if s.hadDiscovery(ctx, sess3.ID) {
|
||||||
t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)")
|
t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)")
|
||||||
}
|
}
|
||||||
@@ -298,12 +430,12 @@ func TestHadDiscoveryAndWriteback(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("createSession: %v", err)
|
t.Fatalf("createSession: %v", err)
|
||||||
}
|
}
|
||||||
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4")
|
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4", 0)
|
||||||
if !s.hadEntityWriteback(ctx, sess4.ID) {
|
if !s.hadEntityWriteback(ctx, sess4.ID) {
|
||||||
t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true")
|
t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true")
|
||||||
}
|
}
|
||||||
// And the discovery+writeback combination (the conv3 scenario).
|
// And the discovery+writeback combination (the conv3 scenario).
|
||||||
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5")
|
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5", 0)
|
||||||
if !s.hadDiscovery(ctx, sess4.ID) {
|
if !s.hadDiscovery(ctx, sess4.ID) {
|
||||||
t.Fatal("hadDiscovery = false after run+writeback, want true")
|
t.Fatal("hadDiscovery = false after run+writeback, want true")
|
||||||
}
|
}
|
||||||
@@ -311,3 +443,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
"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 +187,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)
|
||||||
@@ -213,13 +246,17 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
|||||||
// the seq-order enforcement (5.6) require it to be completed last,
|
// the seq-order enforcement (5.6) require it to be completed last,
|
||||||
// and D.1's complete_task gate enforces the actual calls. Together
|
// and D.1's complete_task gate enforces the actual calls. Together
|
||||||
// they close the loop structurally — neither relies on the agent
|
// they close the loop structurally — neither relies on the agent
|
||||||
// reading SOUL.md.
|
// reading SOUL.md. The match is broadened past the literal tool
|
||||||
|
// names so a natural-language step ("Write back: update entity
|
||||||
|
// attributes…") isn't doubled by an auto-appended duplicate (P1.2).
|
||||||
hasWritebackStep := false
|
hasWritebackStep := false
|
||||||
for _, st := range steps {
|
for _, st := range steps {
|
||||||
if strings.Contains(st.Title, "update_entity_attributes") ||
|
t := strings.ToLower(st.Title + " " + st.Detail)
|
||||||
strings.Contains(st.Title, "create_relationship") ||
|
if strings.Contains(t, "update_entity_attributes") ||
|
||||||
strings.Contains(st.Detail, "update_entity_attributes") ||
|
strings.Contains(t, "create_relationship") ||
|
||||||
strings.Contains(st.Detail, "create_relationship") {
|
strings.Contains(t, "upsert_knowledge") ||
|
||||||
|
strings.Contains(t, "write back") ||
|
||||||
|
strings.Contains(t, "writeback") {
|
||||||
hasWritebackStep = true
|
hasWritebackStep = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -248,8 +285,19 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
|||||||
// The writeback step is now always present (D.2 auto-appends it if
|
// The writeback step is now always present (D.2 auto-appends it if
|
||||||
// the agent forgot), so the old advisory nudge is replaced by the
|
// the agent forgot), so the old advisory nudge is replaced by the
|
||||||
// structural gate: D.1 refuses complete_task without the actual
|
// structural gate: D.1 refuses complete_task without the actual
|
||||||
// update_entity_attributes/create_relationship calls.
|
// update_entity_attributes/create_relationship calls. Enumerate the
|
||||||
result := fmt.Sprintf("Plan set (%d steps)%s. If all steps are read-only, execute now — call update_plan_step(running) + run for each step, no approval needed. If any step is config_mutation/destructive, STOP and wait for operator approval (\"approved\", \"yes\", \"go\", \"proceed\", \"continue\", \"ok\", \"go ahead\"). Do not call propose_plan again.", len(persisted), appendedNote)
|
// step seqs so the model knows exactly which numbers to address with
|
||||||
|
// update_plan_step (seq is 1-based within this plan — the addressing
|
||||||
|
// key, not a global counter).
|
||||||
|
var seqs strings.Builder
|
||||||
|
for i, p := range persisted {
|
||||||
|
if i > 0 {
|
||||||
|
seqs.WriteString("; ")
|
||||||
|
}
|
||||||
|
title := fmt.Sprint(p["title"])
|
||||||
|
fmt.Fprintf(&seqs, "%v=%s", p["seq"], title)
|
||||||
|
}
|
||||||
|
result := fmt.Sprintf("Plan set (%d steps): %s.%s Address them with update_plan_step(seq=N). If all steps are read-only, execute now — call update_plan_step(running) + run for each step, no approval needed. If any step is config_mutation/destructive, STOP and wait for operator approval (\"approved\", \"yes\", \"go\", \"proceed\", \"continue\", \"ok\", \"go ahead\"). Do not call propose_plan again.", len(persisted), seqs.String(), appendedNote)
|
||||||
return result, true
|
return result, true
|
||||||
|
|
||||||
case "update_plan_step":
|
case "update_plan_step":
|
||||||
@@ -259,7 +307,17 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
|||||||
if seq <= 0 || status == "" {
|
if seq <= 0 || status == "" {
|
||||||
return "error: update_plan_step needs seq (>=1) and status", true
|
return "error: update_plan_step needs seq (>=1) and status", true
|
||||||
}
|
}
|
||||||
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID); err != nil {
|
reason, _ := args["replaced_reason"].(string)
|
||||||
|
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil {
|
||||||
|
if errors.Is(err, errPlanStepNotFound) {
|
||||||
|
// The seq doesn't address a step in the CURRENT plan — most
|
||||||
|
// often a stale 1-based number the model carried across a
|
||||||
|
// re-plan, or an out-of-range seq. seq is generation-relative
|
||||||
|
// (1..N within the latest propose_plan), so a superseded
|
||||||
|
// generation's row is never touched (P0.1 fix 3). Direct the
|
||||||
|
// model instead of silently no-op'ing.
|
||||||
|
return fmt.Sprintf("Step %d is not in the current plan. seq is 1-based within your latest propose_plan (a re-plan resets it to 1..N, so an old step number no longer applies). The plan was not changed. Re-address with the correct 1-based seq, or if you've lost track, re-read the plan.", seq), true
|
||||||
|
}
|
||||||
return fmt.Sprintf("error updating step %d: %v", seq, err), true
|
return fmt.Sprintf("error updating step %d: %v", seq, err), true
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Step %d → %s. (Advance with update_plan_step + run; do not re-propose.)", seq, status), true
|
return fmt.Sprintf("Step %d → %s. (Advance with update_plan_step + run; do not re-propose.)", seq, status), true
|
||||||
@@ -317,6 +375,18 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
|||||||
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) && !a.store.hadEntityWriteback(ctx, sessionID) {
|
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) && !a.store.hadEntityWriteback(ctx, sessionID) {
|
||||||
return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true
|
return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true
|
||||||
}
|
}
|
||||||
|
// D.2: refuse success when the goal mentions a reachability/uptime
|
||||||
|
// check but no verification was done. The agent can't claim "X is
|
||||||
|
// reachable" based on a shell command alone — the proxy (Caddy) can
|
||||||
|
// return 200 for a terminal page (ttyd) or fallback while the actual
|
||||||
|
// dashboard is still down. Must call ping_service or run a successful
|
||||||
|
// curl before claiming success.
|
||||||
|
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) {
|
||||||
|
goal := a.store.sessionGoal(ctx, sessionID)
|
||||||
|
if mentionsReachability(goal) && !a.store.hadRecentVerification(ctx, sessionID) {
|
||||||
|
return "Refused: the goal involves a reachability or uptime check (\"make X reachable\", \"get X up\", etc.), but no ping_service call or successful curl/HTTP request against the target was detected. Caddy can return 200 for a terminal or fallback page while the actual service is still down — you must verify the service itself, not just the proxy. Call ping_service(target) or run a curl against the actual service URL, then call complete_task again. Outcome held until verified.", true
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
||||||
if errors.Is(err, errTaskAlreadyComplete) {
|
if errors.Is(err, errTaskAlreadyComplete) {
|
||||||
return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true
|
return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true
|
||||||
@@ -333,6 +403,28 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reachabilityPatterns matches goal text that involves making something
|
||||||
|
// reachable/accessible/working. Used by complete_task to surface a soft
|
||||||
|
// warning when the session goal was about reachability but no verification
|
||||||
|
// occurred before marking success.
|
||||||
|
var reachabilityPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)https?://[^\s]+`),
|
||||||
|
regexp.MustCompile(`(?i)\.hubris\.net\w+`),
|
||||||
|
regexp.MustCompile(`(?i)(un)?reachable`),
|
||||||
|
regexp.MustCompile(`(?i)(not?\s+)?(accessible|reachable|responding|resolving)`),
|
||||||
|
regexp.MustCompile(`(?i)diagnose\s+why`),
|
||||||
|
regexp.MustCompile(`(?i)(fix|restore|bring\s+back).*(accessible|reachable|online)`),
|
||||||
|
}
|
||||||
|
|
||||||
|
func mentionsReachability(goal string) bool {
|
||||||
|
for _, p := range reachabilityPatterns {
|
||||||
|
if p.MatchString(goal) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// autoCompleteTrivialTask is the case-1 fix from
|
// autoCompleteTrivialTask is the case-1 fix from
|
||||||
// plans/2026-07-11-task-completion-safety-net.md: a session that never
|
// plans/2026-07-11-task-completion-safety-net.md: a session that never
|
||||||
// called set_goal never framed itself as a structured task, so a turn that
|
// called set_goal never framed itself as a structured task, so a turn that
|
||||||
|
|||||||
90
cmd/nomos/turngate.go
Normal file
90
cmd/nomos/turngate.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// turnGate enforces at most one in-flight agent turn per session.
|
||||||
|
//
|
||||||
|
// Why this exists (plan 2026-08-03, F1): handleChat runs a turn in the HTTP
|
||||||
|
// request goroutine, and every "resume" path (the empty-message reconnect,
|
||||||
|
// the auto-continuation worker, the idle sweep, answer-question, the /resume
|
||||||
|
// endpoint) launches ANOTHER goroutine running a full turn. Nothing prevented
|
||||||
|
// two turns for the SAME session at once, so a network blip that triggered a
|
||||||
|
// reconnect would spawn a duplicate resumeSession while the original turn was
|
||||||
|
// still alive — their tool calls interleaved on the wire and in the persisted
|
||||||
|
// transcript, which is the root cause behind the "parallel/nesting/sequence
|
||||||
|
// is off" and "task didn't end / flaky" reports.
|
||||||
|
//
|
||||||
|
// Model: one permit (buffered-1 channel seeded with a single token) per
|
||||||
|
// session id. Acquiring consumes the token; releasing puts it back.
|
||||||
|
// - Background/best-effort callers (resumeSession and everything it backs)
|
||||||
|
// use a non-blocking acquire and SKIP when busy — a duplicate nudge while a
|
||||||
|
// turn is already running adds nothing, and the continuation/idle tickers
|
||||||
|
// will retry on their own.
|
||||||
|
// - The live chat path (an operator message) waits briefly for a finishing
|
||||||
|
// background turn, then bails with an actionable error if still busy — see
|
||||||
|
// handleChat.
|
||||||
|
//
|
||||||
|
// The permits map grows one entry per session id seen. For this single-agent
|
||||||
|
// homelab process that set is small and bounded by real sessions; cleanup is
|
||||||
|
// intentionally omitted (a sweep would race with acquire/release and the
|
||||||
|
// memory is negligible).
|
||||||
|
type turnGate struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
permits map[string]chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTurnGate() *turnGate {
|
||||||
|
return &turnGate{permits: make(map[string]chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// permit returns the single token-channel for sessionID, creating and seeding
|
||||||
|
// it on first use. Creation is guarded so two concurrent first-callers for the
|
||||||
|
// same id share one channel.
|
||||||
|
func (g *turnGate) permit(sessionID string) chan struct{} {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
ch, ok := g.permits[sessionID]
|
||||||
|
if !ok {
|
||||||
|
ch = make(chan struct{}, 1)
|
||||||
|
ch <- struct{}{}
|
||||||
|
g.permits[sessionID] = ch
|
||||||
|
}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquire takes the session's permit. With wait <= 0 it is non-blocking
|
||||||
|
// (returns false immediately if a turn is active). With wait > 0 it blocks up
|
||||||
|
// to wait for the permit, returning false on timeout. Every true return MUST
|
||||||
|
// be paired with exactly one release.
|
||||||
|
func (g *turnGate) acquire(sessionID string, wait time.Duration) bool {
|
||||||
|
ch := g.permit(sessionID)
|
||||||
|
if wait <= 0 {
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := time.NewTimer(wait)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
return true
|
||||||
|
case <-t.C:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// release returns the session's permit. Idempotent: a release with no matching
|
||||||
|
// acquire (or a double release) is a no-op rather than a blocking send.
|
||||||
|
func (g *turnGate) release(sessionID string) {
|
||||||
|
ch := g.permit(sessionID)
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
114
cmd/nomos/turngate_test.go
Normal file
114
cmd/nomos/turngate_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTurnGate_NonBlockingSkipsWhenBusy(t *testing.T) {
|
||||||
|
g := newTurnGate()
|
||||||
|
if !g.acquire("s1", 0) {
|
||||||
|
t.Fatal("first non-blocking acquire should succeed on a free session")
|
||||||
|
}
|
||||||
|
// A second non-blocking acquire (a background resume) must skip, not queue.
|
||||||
|
if g.acquire("s1", 0) {
|
||||||
|
t.Fatal("second non-blocking acquire should fail while a turn is active")
|
||||||
|
}
|
||||||
|
// A different session is independent.
|
||||||
|
if !g.acquire("s2", 0) {
|
||||||
|
t.Fatal("acquire on a different session should succeed")
|
||||||
|
}
|
||||||
|
g.release("s2")
|
||||||
|
g.release("s1")
|
||||||
|
// After release, the session is free again.
|
||||||
|
if !g.acquire("s1", 0) {
|
||||||
|
t.Fatal("acquire should succeed again after release")
|
||||||
|
}
|
||||||
|
g.release("s1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTurnGate_BlockingAcquireWaitsForRelease(t *testing.T) {
|
||||||
|
g := newTurnGate()
|
||||||
|
if !g.acquire("s1", 0) {
|
||||||
|
t.Fatal("first acquire should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make(chan bool, 1)
|
||||||
|
go func() { got <- g.acquire("s1", 2*time.Second) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-got:
|
||||||
|
t.Fatal("blocking acquire should wait, not return before release")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// expected: still waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
g.release("s1")
|
||||||
|
select {
|
||||||
|
case ok := <-got:
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("blocking acquire should succeed after release")
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("blocking acquire did not return after release")
|
||||||
|
}
|
||||||
|
g.release("s1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTurnGate_BlockingAcquireTimesOut(t *testing.T) {
|
||||||
|
g := newTurnGate()
|
||||||
|
g.acquire("s1", 0) // hold the permit
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if g.acquire("s1", 60*time.Millisecond) {
|
||||||
|
t.Fatal("acquire should time out while permit is held")
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed < 50*time.Millisecond {
|
||||||
|
t.Fatalf("acquire returned too fast (%v); expected to wait ~60ms", elapsed)
|
||||||
|
}
|
||||||
|
g.release("s1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTurnGate_SingleFlightConcurrent is the core F1 guarantee: many concurrent
|
||||||
|
// background acquirers on the SAME session, exactly one runs at a time. This is
|
||||||
|
// the property that prevents two turns interleaving tool calls.
|
||||||
|
func TestTurnGate_SingleFlightConcurrent(t *testing.T) {
|
||||||
|
g := newTurnGate()
|
||||||
|
const n = 50
|
||||||
|
var inFlight, maxInFlight int64
|
||||||
|
var runs int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(n)
|
||||||
|
start := make(chan struct{})
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
if !g.acquire("shared", 0) { // background-style: skip if busy
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer g.release("shared")
|
||||||
|
cur := atomic.AddInt64(&inFlight, 1)
|
||||||
|
for {
|
||||||
|
m := atomic.LoadInt64(&maxInFlight)
|
||||||
|
if cur <= m || atomic.CompareAndSwapInt64(&maxInFlight, m, cur) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
atomic.AddInt64(&runs, 1)
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
atomic.AddInt64(&inFlight, -1)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if maxInFlight != 1 {
|
||||||
|
t.Fatalf("max in-flight turns = %d, want 1 (turns must not overlap)", maxInFlight)
|
||||||
|
}
|
||||||
|
if runs == 0 {
|
||||||
|
t.Fatal("expected at least one turn to run")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,27 @@
|
|||||||
:80 {
|
:80 {
|
||||||
root * /srv
|
root * /srv
|
||||||
file_server
|
|
||||||
try_files {path} /index.html
|
# /wails/runtime.js is injected by the Wails desktop wrapper, which serves
|
||||||
|
# the same dist/ from its own asset handler. In a browser it does not
|
||||||
|
# exist, and the SPA fallback below answered it with index.html — so the
|
||||||
|
# browser parsed "<!doctype html>" as JavaScript and threw
|
||||||
|
# "SyntaxError: expected expression, got '<'" on every page load.
|
||||||
|
# Return a real 404 instead: the tag fails quietly, and the desktop app is
|
||||||
|
# unaffected because it never reaches this server.
|
||||||
|
handle /wails/* {
|
||||||
|
error 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# Same reasoning for any other asset: a missing .js/.css/.map answered with
|
||||||
|
# HTML is always a confusing parse error rather than an honest 404. Only
|
||||||
|
# real routes should fall through to the SPA.
|
||||||
|
@asset path_regexp \.(js|mjs|css|map|json|png|jpg|svg|ico|woff2?)$
|
||||||
|
handle @asset {
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
file_server
|
||||||
|
try_files {path} /index.html
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ FROM node:22-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /build/web
|
WORKDIR /build/web
|
||||||
COPY web/package.json web/package-lock.json ./
|
COPY web/package.json web/package-lock.json ./
|
||||||
RUN npm ci
|
COPY web/vendor /build/vendor
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
COPY VERSION ./
|
COPY VERSION ./
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ services:
|
|||||||
command: ["api"]
|
command: ["api"]
|
||||||
stop_signal: SIGTERM
|
stop_signal: SIGTERM
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
|
# Exists so nomos can wait for the API to actually answer rather than just
|
||||||
|
# for its container to exist — see nomos's depends_on below. wget is
|
||||||
|
# BusyBox's, already in the alpine runtime image, so this adds no
|
||||||
|
# dependency. /healthz pings the DB, so "healthy" means genuinely ready.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8090/healthz"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
# Migrations and seed run before this container, but the first bind can
|
||||||
|
# still take a moment; failures inside the start period don't count.
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
# Scheduler (Phase 3) — observe loop
|
# Scheduler (Phase 3) — observe loop
|
||||||
scheduler:
|
scheduler:
|
||||||
@@ -139,7 +151,12 @@ services:
|
|||||||
profiles: ["full"]
|
profiles: ["full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_started
|
# service_started only waits for the container to exist, so nomos came
|
||||||
|
# up while the API was still binding :8090, failed its MCP initialize,
|
||||||
|
# exited 1, and crash-looped for ~25s on every single deploy. It always
|
||||||
|
# recovered, which is exactly why it went unnoticed. service_healthy
|
||||||
|
# waits for the API to actually answer.
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NOMOS_MCP_URL: http://api:8090/mcp
|
NOMOS_MCP_URL: http://api:8090/mcp
|
||||||
NOMOS_AGENT_SLUG: agent:nomos
|
NOMOS_AGENT_SLUG: agent:nomos
|
||||||
|
|||||||
@@ -234,9 +234,33 @@ sequenceDiagram
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**2026-07-08 — renamed to Nomos.** The Hermes agent gateway was renamed to
|
**2026-07-08 — renamed to Nomos.**
|
||||||
Nomos (from *oikonomos*, the steward of the oikos) under the
|
Nomos (from *oikonomos*, the steward of the oikos) under the
|
||||||
[Nomos resident agent plan](../../plans/2026-07-08-nomos-resident-agent.md),
|
[Nomos resident agent plan](../../plans/2026-07-08-nomos-resident-agent.md),
|
||||||
|
|
||||||
|
### Hermes MCP client setup
|
||||||
|
|
||||||
|
To connect a Hermes Agent instance to oikos as a native MCP client, add to
|
||||||
|
`~/.hermes/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
oikos:
|
||||||
|
url: "https://mcp.hubris.network/mcp"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer <OIKOS_MCP_BEARER_TOKEN>"
|
||||||
|
timeout: 180
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `/reload-mcp` in-session or restart Hermes. Tools appear as
|
||||||
|
`mcp__oikos__*`.
|
||||||
|
|
||||||
|
**Caveat:** Hermes stores the bearer token in plaintext in `config.yaml` —
|
||||||
|
it does not support `${VAR}` interpolation in MCP server headers. Ensure
|
||||||
|
`security.redact_secrets: true` (default) so the token value is stripped
|
||||||
|
from tool output and logs. File an upstream feature request at
|
||||||
|
https://github.com/NousResearch/hermes-agent/issues for env-var
|
||||||
|
interpolation support.
|
||||||
N0 milestone. The gateway binary (`cmd/nomos`), Docker service, DB slug
|
N0 milestone. The gateway binary (`cmd/nomos`), Docker service, DB slug
|
||||||
(`agent:nomos`), and all referencing docs were updated. All architectural
|
(`agent:nomos`), and all referencing docs were updated. All architectural
|
||||||
principles in this ADR remain unchanged.
|
principles in this ADR remain unchanged.
|
||||||
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)
|
||||||
133
internal/audit/audit.go
Normal file
133
internal/audit/audit.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// Package audit produces read-only drift reports over the knowledge graph and
|
||||||
|
// monitoring state. It is the shared engine behind the
|
||||||
|
// /api/v1/audit/drift endpoint and the audit_knowledge_graph MCP tool.
|
||||||
|
//
|
||||||
|
// It surfaces the structural gaps an operator otherwise discovers only by
|
||||||
|
// accident: orphan check entities, checks targeting retired entities, probes
|
||||||
|
// stuck down/unknown, unmonitored declared types, and live edges pointing at
|
||||||
|
// destroyed/deprecated targets. Live-infra discovery (pct/docker/certs) is a
|
||||||
|
// follow-up that needs host-hop execution; these categories are pure DB
|
||||||
|
// queries, so the report is cheap, safe to run unattended, and testable.
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding is one drift item the operator should look at.
|
||||||
|
type Finding struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Severity string `json:"severity"` // info | warning | critical
|
||||||
|
Count int `json:"count"`
|
||||||
|
Entities []string `json:"entities"`
|
||||||
|
Evidence string `json:"evidence"`
|
||||||
|
SuggestedRunbook string `json:"suggested_runbook"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary tallies findings by category.
|
||||||
|
type Summary struct {
|
||||||
|
TotalFindings int `json:"total_findings"`
|
||||||
|
ByCategory map[string]int `json:"by_category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report runs every drift check and returns the findings plus a summary.
|
||||||
|
func Report(ctx context.Context, pool *db.Pool) ([]Finding, Summary) {
|
||||||
|
specs := []struct {
|
||||||
|
finding Finding
|
||||||
|
query string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Finding{Category: "orphan_checks", Severity: "warning",
|
||||||
|
Evidence: "check entities with truncated/random slugs (legacy shortSlug bug), no live target",
|
||||||
|
SuggestedRunbook: "scripts/cleanup-orphan-checks.sh"},
|
||||||
|
`SELECT e.slug FROM entities e
|
||||||
|
WHERE e.type = 'check'
|
||||||
|
AND e.slug ~ '^check:(ping|ssh-script|disk):[0-9a-f]{8}$'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Finding{Category: "dead_checks", Severity: "warning",
|
||||||
|
Evidence: "enabled check_defs whose target entity is deprecated/destroyed",
|
||||||
|
SuggestedRunbook: "lifecycle-deprecate-node / lifecycle-destroy-node"},
|
||||||
|
`SELECT e.slug FROM check_defs cd
|
||||||
|
JOIN entities e ON e.id = cd.entity_id
|
||||||
|
JOIN entities tgt ON tgt.id = cd.target_id
|
||||||
|
WHERE cd.enabled AND tgt.state IN ('deprecated','destroyed')`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Finding{Category: "down_checks", Severity: "critical",
|
||||||
|
Evidence: "enabled checks reporting health=down",
|
||||||
|
SuggestedRunbook: "service-health-check"},
|
||||||
|
`SELECT e.slug FROM check_defs cd JOIN entities e ON e.id = cd.entity_id
|
||||||
|
WHERE cd.enabled AND cd.last_health = 'down'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Finding{Category: "unknown_checks", Severity: "warning",
|
||||||
|
Evidence: "enabled checks that ran but reported health=unknown (likely misconfigured probe)",
|
||||||
|
SuggestedRunbook: "knowledge-graph-audit"},
|
||||||
|
`SELECT e.slug FROM check_defs cd JOIN entities e ON e.id = cd.entity_id
|
||||||
|
WHERE cd.enabled AND cd.last_health = 'unknown'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Finding{Category: "unmonitored", Severity: "warning",
|
||||||
|
Evidence: "active entities whose type declares monitoring but have no enabled check_def",
|
||||||
|
SuggestedRunbook: "knowledge-graph-audit"},
|
||||||
|
`SELECT DISTINCT e.slug FROM signals sg
|
||||||
|
JOIN entities e ON e.id = sg.target_entity_id
|
||||||
|
WHERE sg.kind = 'unmonitored' AND sg.state IN ('raised','acknowledged','acting')`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Finding{Category: "dangling_edges", Severity: "warning",
|
||||||
|
Evidence: "live relationships (hosts/provides/mounts) pointing at destroyed/deprecated targets",
|
||||||
|
SuggestedRunbook: "lifecycle-destroy-node"},
|
||||||
|
`SELECT src.slug || ' -' || r.type || '-> ' || tgt.slug FROM relationships r
|
||||||
|
JOIN entities src ON src.id = r.source_id
|
||||||
|
JOIN entities tgt ON tgt.id = r.target_id
|
||||||
|
WHERE r.valid_to IS NULL
|
||||||
|
AND src.state NOT IN ('destroyed','deprecated')
|
||||||
|
AND tgt.state IN ('destroyed','deprecated')`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Finding{Category: "polluted_attrs", Severity: "warning",
|
||||||
|
Evidence: "routing-critical attributes carrying prose (breaks resolution) — e.g. host='hubris (confirmed via pct…')",
|
||||||
|
SuggestedRunbook: "knowledge-graph-audit"},
|
||||||
|
`SELECT slug || ': host=' || (attributes->>'host') FROM entities
|
||||||
|
WHERE attributes->>'host' IS NOT NULL
|
||||||
|
AND (attributes->>'host') ~ '[ (]'`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
findings := make([]Finding, 0, len(specs))
|
||||||
|
summary := Summary{ByCategory: map[string]int{}}
|
||||||
|
for _, sp := range specs {
|
||||||
|
f := runFinding(ctx, pool, sp.finding, sp.query)
|
||||||
|
findings = append(findings, f)
|
||||||
|
summary.TotalFindings += f.Count
|
||||||
|
summary.ByCategory[f.Category] = f.Count
|
||||||
|
}
|
||||||
|
return findings, summary
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityCap = 50
|
||||||
|
|
||||||
|
// runFinding runs a single-column slug query and folds the rows into a Finding.
|
||||||
|
func runFinding(ctx context.Context, pool *db.Pool, f Finding, query string) Finding {
|
||||||
|
rows, err := pool.Query(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
f.Evidence = f.Evidence + " (query error: " + err.Error() + ")"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var slug string
|
||||||
|
if err := rows.Scan(&slug); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.Count++
|
||||||
|
if len(f.Entities) < entityCap {
|
||||||
|
f.Entities = append(f.Entities, slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
69
internal/audit/audit_test.go
Normal file
69
internal/audit/audit_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Integration tests against a real Postgres, guarded by
|
||||||
|
// OIKOS_TEST_DATABASE_URL (same convention as internal/scheduler).
|
||||||
|
|
||||||
|
func newAuditPool(t *testing.T) *db.Pool {
|
||||||
|
t.Helper()
|
||||||
|
base := getenvOrDefault("OIKOS_TEST_DATABASE_URL", "")
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
|
||||||
|
}
|
||||||
|
return createTestDB(t, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportFlagsOrphanAndDeadAndDown(t *testing.T) {
|
||||||
|
pool := newAuditPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// An orphan check entity (truncated random slug, the legacy bug shape).
|
||||||
|
mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
|
VALUES ($1,'check:ssh-script:0d31fdd1','check','check:ssh-script:0d31fdd1','active','{}'::jsonb,1,now(),now())`, uuid.New())
|
||||||
|
|
||||||
|
// An active entity + a check_def on it stuck down.
|
||||||
|
target := uuid.New()
|
||||||
|
mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
|
VALUES ($1,'service:demo','service','demo','active','{}'::jsonb,1,now(),now())`, target)
|
||||||
|
checkE := uuid.New()
|
||||||
|
mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
|
VALUES ($1,'check:http:service:demo:0','check','c','active','{}'::jsonb,1,now(),now())`, checkE)
|
||||||
|
mustExec(t, pool, ctx, `INSERT INTO check_defs (entity_id, target_id, target_type, kind, config, interval_s, timeout_s, enabled, last_run_at, last_health)
|
||||||
|
VALUES ($1,$2,'service','http','{}'::jsonb,60,30,true,now(),'down')`, checkE, target)
|
||||||
|
|
||||||
|
// A deprecated entity still carrying an enabled check (dead_checks).
|
||||||
|
dep := uuid.New()
|
||||||
|
mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
|
VALUES ($1,'service:old','service','old','deprecated','{}'::jsonb,1,now(),now())`, dep)
|
||||||
|
depCheck := uuid.New()
|
||||||
|
mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
|
VALUES ($1,'check:http:service:old:0','check','c','active','{}'::jsonb,1,now(),now())`, depCheck)
|
||||||
|
mustExec(t, pool, ctx, `INSERT INTO check_defs (entity_id, target_id, target_type, kind, config, interval_s, timeout_s, enabled, last_run_at)
|
||||||
|
VALUES ($1,$2,'service','http','{}'::jsonb,60,30,true,now())`, depCheck, dep)
|
||||||
|
|
||||||
|
findings, summary := Report(ctx, pool)
|
||||||
|
|
||||||
|
byCat := map[string]int{}
|
||||||
|
for _, f := range findings {
|
||||||
|
byCat[f.Category] = f.Count
|
||||||
|
}
|
||||||
|
if byCat["orphan_checks"] < 1 {
|
||||||
|
t.Errorf("orphan_checks = %d, want >=1", byCat["orphan_checks"])
|
||||||
|
}
|
||||||
|
if byCat["down_checks"] < 1 {
|
||||||
|
t.Errorf("down_checks = %d, want >=1", byCat["down_checks"])
|
||||||
|
}
|
||||||
|
if byCat["dead_checks"] < 1 {
|
||||||
|
t.Errorf("dead_checks = %d, want >=1", byCat["dead_checks"])
|
||||||
|
}
|
||||||
|
if summary.TotalFindings < 3 {
|
||||||
|
t.Errorf("TotalFindings = %d, want >=3", summary.TotalFindings)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/audit/testutil_test.go
Normal file
67
internal/audit/testutil_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestDB provisions a throwaway migrated database, same convention as
|
||||||
|
// internal/scheduler/coverage_test.go.
|
||||||
|
func createTestDB(t *testing.T, baseURL string) *db.Pool {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
admin, err := pgx.Connect(ctx, baseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect admin: %v", err)
|
||||||
|
}
|
||||||
|
dbName := fmt.Sprintf("oikos_aud_%08x", rand.Int63())
|
||||||
|
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
|
||||||
|
admin.Close(ctx)
|
||||||
|
t.Fatalf("create test db: %v", err)
|
||||||
|
}
|
||||||
|
admin.Close(ctx)
|
||||||
|
|
||||||
|
at := strings.LastIndex(baseURL, "/")
|
||||||
|
testURL := baseURL[:at+1] + dbName
|
||||||
|
if q := strings.Index(baseURL[at:], "?"); q >= 0 {
|
||||||
|
testURL += baseURL[at+q:]
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := db.New(ctx, testURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect test db: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.Migrate(ctx); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Close()
|
||||||
|
if admin, err := pgx.Connect(ctx, baseURL); err == nil {
|
||||||
|
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
|
||||||
|
admin.Close(ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvOrDefault(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustExec(t *testing.T, pool *db.Pool, ctx context.Context, q string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(ctx, q, args...); err != nil {
|
||||||
|
t.Fatalf("exec %s: %v", q, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,540 @@
|
|||||||
|
// Package checkdefaults derives an entity's default check_defs from the
|
||||||
|
// monitoring kinds its type declares in seeds/ontology.yaml.
|
||||||
|
//
|
||||||
|
// The type says WHAT to watch (`service: [http, process]`); this package
|
||||||
|
// works out HOW — which concrete check_defs rows to write, and what host,
|
||||||
|
// script or URL each needs. Deriving config here rather than in YAML keeps
|
||||||
|
// the ontology declarative and keeps address resolution (which has to walk
|
||||||
|
// the graph) in code.
|
||||||
package checkdefaults
|
package checkdefaults
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/ontology"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CheckDef struct {
|
// Semantic monitoring kinds, as declared on entity types. These are not
|
||||||
Kind string
|
// check_defs.kind values — one semantic kind can expand to several concrete
|
||||||
Script string
|
// checks (`resource` becomes four ssh-script rows).
|
||||||
Host string
|
const (
|
||||||
User string
|
KindPing = "ping"
|
||||||
Port int
|
KindResource = "resource"
|
||||||
Thresholds map[string]any
|
KindUpdates = "updates"
|
||||||
Extra map[string]any
|
KindProcess = "process"
|
||||||
|
KindHTTP = "http"
|
||||||
|
KindCapacity = "capacity"
|
||||||
|
KindBackup = "backup-freshness"
|
||||||
|
KindCertExpiry = "cert-expiry"
|
||||||
|
KindVMStatus = "vm-status"
|
||||||
|
KindDNS = "dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultBackupMaxAge is how long a backup target may go without a new
|
||||||
|
// artifact before it is stale. A day suits the nightly jobs in this lab;
|
||||||
|
// override per target with `backup_max_age_s` in the entity's attributes.
|
||||||
|
const defaultBackupMaxAge = 86400
|
||||||
|
|
||||||
|
// Target is the entity default checks are being ensured for.
|
||||||
|
type Target struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Slug string
|
||||||
|
Type string
|
||||||
|
// Name is the entity's name column, not an attribute. The old code read
|
||||||
|
// attrs["name"], which is never populated — seeds put `name` beside
|
||||||
|
// `attributes`, not inside it — so every service silently produced no
|
||||||
|
// process check.
|
||||||
|
Name string
|
||||||
|
Attrs []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveHost(attrs map[string]any) string {
|
// Result reports what Ensure did, so callers can log a type that declared
|
||||||
|
// monitoring but produced nothing instead of failing silently.
|
||||||
|
type Result struct {
|
||||||
|
Created int
|
||||||
|
// Skipped records kinds that were declared but could not be built, with
|
||||||
|
// the reason. A non-empty Skipped on an active entity is a real gap.
|
||||||
|
Skipped []Skip
|
||||||
|
// Undeclared is true when no ancestor of the type declared monitoring —
|
||||||
|
// an ontology gap rather than a fleet gap.
|
||||||
|
Undeclared bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip is one declared-but-unbuilt check kind.
|
||||||
|
type Skip struct {
|
||||||
|
Kind string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkDef struct {
|
||||||
|
kind string
|
||||||
|
config map[string]any
|
||||||
|
interval int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure writes the default check_defs for one entity, idempotently.
|
||||||
|
//
|
||||||
|
// Returns the number of checks created. An entity whose type declares
|
||||||
|
// monitoring it cannot satisfy comes back with a populated Skipped rather
|
||||||
|
// than an error — a missing address is a modelling gap, not a failure of
|
||||||
|
// this call.
|
||||||
|
func Ensure(ctx context.Context, tx pgx.Tx, tree *ontology.TypeTree, t Target) (Result, error) {
|
||||||
|
var res Result
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO entity_status (entity_id, health, updated_at)
|
||||||
|
VALUES ($1, 'unknown', now())
|
||||||
|
ON CONFLICT (entity_id) DO NOTHING`, t.ID); err != nil {
|
||||||
|
return res, fmt.Errorf("entity_status %s: %w", t.Slug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mon := tree.Monitoring(t.Type)
|
||||||
|
if !mon.Declared {
|
||||||
|
res.Undeclared = true
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
if mon.None() {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var attrs map[string]any
|
||||||
|
if len(t.Attrs) > 0 {
|
||||||
|
_ = json.Unmarshal(t.Attrs, &attrs)
|
||||||
|
}
|
||||||
|
if attrs == nil {
|
||||||
|
attrs = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-entity override: an explicit `monitoring` attribute wins over the
|
||||||
|
// type declaration. A single entity can opt out (monitoring: none) or pick
|
||||||
|
// different kinds without introducing a new type — e.g. service:haos opts
|
||||||
|
// out because its VM is already covered by a vm-status check and the
|
||||||
|
// service can't be SSH-probed (haos blocks SSH).
|
||||||
|
if mo, ok := attrs["monitoring"]; ok {
|
||||||
|
mon = resolveMonitoringAttr(mo, mon)
|
||||||
|
if mon.None() {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A service has no address of its own — it lives on the container that
|
||||||
|
// provides it. Fall back to the graph before giving up.
|
||||||
|
host := resolveHost(attrs)
|
||||||
|
if host == "" {
|
||||||
|
hostAttrs, err := hostViaGraph(ctx, tx, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("resolve host for %s: %w", t.Slug, err)
|
||||||
|
}
|
||||||
|
host = resolveHost(hostAttrs)
|
||||||
|
if user := resolveSSHUser(hostAttrs); host != "" && user != "root" {
|
||||||
|
attrs["ssh"] = hostAttrs["ssh"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user := resolveSSHUser(attrs)
|
||||||
|
port := resolveSSHPort(attrs)
|
||||||
|
|
||||||
|
var defs []checkDef
|
||||||
|
for _, kind := range mon.Kinds {
|
||||||
|
built, reason := buildKind(kind, t, attrs, host, user, port)
|
||||||
|
if len(built) == 0 {
|
||||||
|
res.Skipped = append(res.Skipped, Skip{Kind: kind, Reason: reason})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defs = append(defs, built...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, def := range defs {
|
||||||
|
created, err := writeCheck(ctx, tx, t, i, def)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("check %s/%s: %w", t.Slug, def.kind, err)
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
res.Created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMonitoringAttr turns an entity's `monitoring` attribute into a
|
||||||
|
// MonitoringResolution that overrides the type's declaration. Accepts the
|
||||||
|
// scalar "none" (or empty) to opt out, or a list of kind strings to override.
|
||||||
|
func resolveMonitoringAttr(v any, fallback ontology.MonitoringResolution) ontology.MonitoringResolution {
|
||||||
|
switch vv := v.(type) {
|
||||||
|
case string:
|
||||||
|
if vv == "none" || vv == "" {
|
||||||
|
return ontology.MonitoringResolution{Declared: true, Source: "attribute"}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
kinds := make([]string, 0, len(vv))
|
||||||
|
for _, k := range vv {
|
||||||
|
if s, ok := k.(string); ok && s != "" {
|
||||||
|
kinds = append(kinds, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ontology.MonitoringResolution{Declared: true, Kinds: kinds, Source: "attribute"}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildKind turns one declared semantic kind into concrete check_defs, or
|
||||||
|
// returns the reason it could not.
|
||||||
|
func buildKind(kind string, t Target, attrs map[string]any, host, user string, port int) ([]checkDef, string) {
|
||||||
|
ssh := func(script string, args ...string) checkDef {
|
||||||
|
cfg := map[string]any{"script": script, "host": host}
|
||||||
|
if user != "" && user != "root" {
|
||||||
|
cfg["user"] = user
|
||||||
|
}
|
||||||
|
if port != 0 && port != 22 {
|
||||||
|
cfg["port"] = port
|
||||||
|
}
|
||||||
|
if len(args) > 0 && args[0] != "" {
|
||||||
|
cfg["args"] = args[0]
|
||||||
|
}
|
||||||
|
return checkDef{kind: "ssh-script", config: cfg, interval: 60}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case KindPing:
|
||||||
|
if host == "" {
|
||||||
|
return nil, "no address on the entity or its host"
|
||||||
|
}
|
||||||
|
return []checkDef{{kind: "ping", config: map[string]any{"host": host}, interval: 30}}, ""
|
||||||
|
|
||||||
|
case KindResource:
|
||||||
|
if host == "" {
|
||||||
|
return nil, "no address on the entity or its host"
|
||||||
|
}
|
||||||
|
return []checkDef{
|
||||||
|
ssh("cpu_check.sh"), ssh("memory_check.sh"),
|
||||||
|
ssh("load_check.sh"), ssh("disk_usage_check.sh"),
|
||||||
|
}, ""
|
||||||
|
|
||||||
|
case KindUpdates:
|
||||||
|
if host == "" {
|
||||||
|
return nil, "no address on the entity or its host"
|
||||||
|
}
|
||||||
|
// Daily. updates_check.sh runs `apt update` against the distro
|
||||||
|
// mirrors; the shared 60s ssh-script default would have meant 1,440
|
||||||
|
// mirror hits per machine per day to answer a question whose answer
|
||||||
|
// changes about once a day.
|
||||||
|
u := ssh("updates_check.sh")
|
||||||
|
u.interval = 86400
|
||||||
|
return []checkDef{u}, ""
|
||||||
|
|
||||||
|
case KindCapacity:
|
||||||
|
if host == "" {
|
||||||
|
return nil, "no address on the entity or its host"
|
||||||
|
}
|
||||||
|
return []checkDef{ssh("disk_usage_check.sh")}, ""
|
||||||
|
|
||||||
|
case KindProcess:
|
||||||
|
if host == "" {
|
||||||
|
return nil, "no address on the entity or its host"
|
||||||
|
}
|
||||||
|
// A service's name is a logical label, not usually its systemd unit
|
||||||
|
// or container name (matrix = matrix-synapse.service + containers).
|
||||||
|
// Prefer an explicit probe target when declared; process_check.sh also
|
||||||
|
// matches a unit prefix or a docker container as a fallback.
|
||||||
|
unit := ""
|
||||||
|
for _, key := range []string{"probe_unit", "systemd_unit", "container"} {
|
||||||
|
if v, _ := attrs[key].(string); v != "" {
|
||||||
|
unit = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ontology intent: "http when it has a url, else a process check." A
|
||||||
|
// url-fronted service is already liveness-probed via http (the real
|
||||||
|
// endpoint, through the TLS terminator); the process check is redundant
|
||||||
|
// and fragile (needs host access + the exact unit/container name), and
|
||||||
|
// under worst-of aggregation it lets a broken supplementary probe veto
|
||||||
|
// a working service. Emit it only for services WITHOUT a url, or when
|
||||||
|
// an explicit probe_unit opts into binary-level depth.
|
||||||
|
if unit == "" {
|
||||||
|
if httpURL(t, attrs) != "" {
|
||||||
|
return nil, "url present and no probe_unit; http check covers liveness"
|
||||||
|
}
|
||||||
|
unit = t.Name
|
||||||
|
}
|
||||||
|
if unit == "" {
|
||||||
|
return nil, "no name to check a process for"
|
||||||
|
}
|
||||||
|
// process_check.sh takes the unit/container name as $1 and reports
|
||||||
|
// "unknown" without it.
|
||||||
|
return []checkDef{ssh("process_check.sh", unit)}, ""
|
||||||
|
|
||||||
|
case KindBackup:
|
||||||
|
// A backup target is checked from the machine that writes to it, so it
|
||||||
|
// needs both an address (resolved via the backs-up-to edge) and the
|
||||||
|
// path to look at.
|
||||||
|
path, _ := attrs["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return nil, "entity carries no path attribute to check for backups"
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return nil, "no address on the entity or whatever backs up to it"
|
||||||
|
}
|
||||||
|
maxAge := defaultBackupMaxAge
|
||||||
|
if v, ok := attrs["backup_max_age_s"].(float64); ok && v > 0 {
|
||||||
|
maxAge = int(v)
|
||||||
|
}
|
||||||
|
cfg := map[string]any{"path": path, "host": host, "max_age_s": maxAge}
|
||||||
|
if user != "" && user != "root" {
|
||||||
|
cfg["user"] = user
|
||||||
|
}
|
||||||
|
if port != 0 && port != 22 {
|
||||||
|
cfg["port"] = port
|
||||||
|
}
|
||||||
|
// Daily. The freshness budget itself is a day, so probing more often
|
||||||
|
// cannot surface anything sooner — it just costs an SSH round trip.
|
||||||
|
return []checkDef{{kind: "backup-freshness", config: cfg, interval: 86400}}, ""
|
||||||
|
|
||||||
|
case KindHTTP:
|
||||||
|
url := httpURL(t, attrs)
|
||||||
|
if url == "" {
|
||||||
|
return nil, "no url attribute, public_host, or hostname-shaped name"
|
||||||
|
}
|
||||||
|
// max_status rather than an exact expected_status: most services sit
|
||||||
|
// behind Authentik and answer 302/401, which is a working service.
|
||||||
|
return []checkDef{{
|
||||||
|
kind: "http",
|
||||||
|
config: map[string]any{"url": url, "max_status": 500},
|
||||||
|
interval: 60,
|
||||||
|
}}, ""
|
||||||
|
|
||||||
|
case KindDNS:
|
||||||
|
// Resolve the entity's name via DNS to verify the zone is reachable.
|
||||||
|
// Uses the entity name (zone apex) or falls back to the slug.
|
||||||
|
name := t.Name
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimPrefix(t.Slug, "zone:")
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return nil, "no name to resolve"
|
||||||
|
}
|
||||||
|
return []checkDef{{
|
||||||
|
kind: "dns",
|
||||||
|
config: map[string]any{"name": name},
|
||||||
|
interval: 300, // 5 min — DNS changes are rare; the cost of a miss
|
||||||
|
// is a stale IP, not a service outage.
|
||||||
|
}}, ""
|
||||||
|
|
||||||
|
case KindCertExpiry:
|
||||||
|
// The host whose cert to read (SNI / cert CN). Prefer an explicit
|
||||||
|
// `hostname` attribute, then `cn`, then a dotted name. Hourly: expiry
|
||||||
|
// changes once a day, but a renewal or mis-issued cert is worth
|
||||||
|
// noticing within the hour.
|
||||||
|
host := certHost(t, attrs)
|
||||||
|
if host == "" {
|
||||||
|
return nil, "no hostname / cn / dotted name to dial for the cert"
|
||||||
|
}
|
||||||
|
// `dial` is the TLS terminator's address to connect to (Caddy's lab
|
||||||
|
// IP), used when the hostname doesn't resolve/reach from the scheduler.
|
||||||
|
// Without it the probe can't reach *.hubris.network from a container
|
||||||
|
// with no mesh / split-horizon DNS.
|
||||||
|
dial, _ := attrs["dial"].(string)
|
||||||
|
config := map[string]any{"host": host, "warn_days": 30, "crit_days": 7}
|
||||||
|
if dial != "" {
|
||||||
|
config["dial"] = dial
|
||||||
|
}
|
||||||
|
return []checkDef{{
|
||||||
|
kind: "cert-expiry",
|
||||||
|
config: config,
|
||||||
|
interval: 3600,
|
||||||
|
}}, ""
|
||||||
|
|
||||||
|
case KindVMStatus:
|
||||||
|
// "Is the VM powered on" via `qm status` on its Proxmox host — the
|
||||||
|
// right reachability probe for a VM, since many block ICMP and lack a
|
||||||
|
// guest agent. checkVMStatus re-reads pve_id + host at runtime.
|
||||||
|
if _, ok := attrs["pve_id"]; !ok {
|
||||||
|
return nil, "no pve_id to run qm status"
|
||||||
|
}
|
||||||
|
return []checkDef{{
|
||||||
|
kind: "vm-status",
|
||||||
|
config: map[string]any{},
|
||||||
|
interval: 60,
|
||||||
|
}}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "no builder for this kind yet"
|
||||||
|
}
|
||||||
|
|
||||||
|
// certHost works out the hostname to TLS-dial for a certificate's expiry.
|
||||||
|
func certHost(t Target, attrs map[string]any) string {
|
||||||
|
for _, key := range []string{"hostname", "cn", "san"} {
|
||||||
|
if v, ok := attrs[key].(string); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A dotted name is a hostname (hubris.network, media.hubris.network).
|
||||||
|
if strings.Contains(t.Name, ".") && !strings.Contains(t.Name, " ") {
|
||||||
|
return t.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCheck upserts one check_def and its backing check entity.
|
||||||
|
//
|
||||||
|
// The entity upsert MUST return the row's id. The previous version generated
|
||||||
|
// a fresh uuid, inserted ON CONFLICT (slug) DO NOTHING, then wrote a
|
||||||
|
// check_defs row referencing that uuid. On any re-seed the slug already
|
||||||
|
// existed, the entity insert became a no-op, and the check_defs insert
|
||||||
|
// violated its foreign key — which aborted the whole ingest transaction and
|
||||||
|
// made every subsequent statement fail with 25P02. Because the errors were
|
||||||
|
// discarded, the only visible symptom was an unrelated failure much later.
|
||||||
|
func writeCheck(ctx context.Context, tx pgx.Tx, t Target, idx int, def checkDef) (bool, error) {
|
||||||
|
// The full target slug, not a truncation of it. shortSlug() took the last
|
||||||
|
// 8 characters, so all 21 ingress routes collapsed to ".network" and
|
||||||
|
// generated one identical check slug — they overwrote each other and 20
|
||||||
|
// of them ended up with no check at all. It also collided service:jellyfin
|
||||||
|
// with lxc:jellyfin. Entity slugs are unique; use them.
|
||||||
|
checkSlug := fmt.Sprintf("check:%s:%s:%d", def.kind, t.Slug, idx)
|
||||||
|
|
||||||
|
newID, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
newID = uuid.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkID uuid.UUID
|
||||||
|
err = tx.QueryRow(ctx,
|
||||||
|
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, 'check', $2, 'active', '{}', 1, now(), now())
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET updated_at = now()
|
||||||
|
RETURNING id`,
|
||||||
|
newID, checkSlug).Scan(&checkID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("upsert check entity %s: %w", checkSlug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := json.Marshal(def.config)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is derived from the seed, so the seed wins on re-ingest and
|
||||||
|
// attribute changes propagate. `enabled` is deliberately left alone: it
|
||||||
|
// is operational state an operator may have toggled.
|
||||||
|
// last_run_at is seeded to a random point inside the interval so checks
|
||||||
|
// created together do not stay in lockstep. Every check the seed creates
|
||||||
|
// would otherwise come due in the same instant forever: ~165 probes
|
||||||
|
// landing at once each minute rather than spread across it. Deliberately
|
||||||
|
// absent from the DO UPDATE below — a re-seed must not reset the schedule
|
||||||
|
// and re-herd everything.
|
||||||
|
tag, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO check_defs (entity_id, target_id, target_type, kind, config, interval_s, timeout_s, enabled, last_run_at)
|
||||||
|
VALUES ($1, $2, $6, $3, $4, $5, 30, true,
|
||||||
|
now() - make_interval(secs => random() * $5::int))
|
||||||
|
ON CONFLICT (entity_id) DO UPDATE
|
||||||
|
SET target_id = EXCLUDED.target_id, target_type = EXCLUDED.target_type,
|
||||||
|
kind = EXCLUDED.kind,
|
||||||
|
config = EXCLUDED.config, interval_s = EXCLUDED.interval_s,
|
||||||
|
updated_at = now()`,
|
||||||
|
checkID, t.ID, def.kind, configJSON, def.interval, t.Type)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("upsert check_def %s: %w", checkSlug, err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpURL works out what to GET for an http check.
|
||||||
|
//
|
||||||
|
// Ingress routes carry their hostname as the entity name rather than as an
|
||||||
|
// attribute (`name: media.hubris.network`), and most declare no attributes at
|
||||||
|
// all — so the name is the only thing to go on. Requiring a `url` attribute
|
||||||
|
// left all 21 of them unmonitored, which is a shame given an ingress check is
|
||||||
|
// the most end-to-end probe available: it exercises Caddy, DNS, TLS and the
|
||||||
|
// upstream in one request.
|
||||||
|
func httpURL(t Target, attrs map[string]any) string {
|
||||||
|
if url, ok := attrs["url"].(string); ok && url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if h, ok := attrs["public_host"].(string); ok && h != "" {
|
||||||
|
return "https://" + h
|
||||||
|
}
|
||||||
|
// A dotted name is a hostname; a service name like "jellyfin" is not.
|
||||||
|
if strings.Contains(t.Name, ".") && !strings.Contains(t.Name, " ") {
|
||||||
|
return "https://" + t.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostViaGraph returns the attributes of the entity that hosts or provides
|
||||||
|
// this one, so a service can inherit its container's address.
|
||||||
|
func hostViaGraph(ctx context.Context, tx pgx.Tx, entityID uuid.UUID) (map[string]any, error) {
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT e.attributes
|
||||||
|
FROM relationships r
|
||||||
|
JOIN entities e ON e.id = r.source_id
|
||||||
|
WHERE r.target_id = $1
|
||||||
|
AND r.valid_to IS NULL
|
||||||
|
-- backs-up-to points from the thing being backed up TO the target,
|
||||||
|
-- so walking it backwards finds the machine that writes the backups
|
||||||
|
-- — which is the only place a freshness check can run.
|
||||||
|
AND r.type IN ('provides', 'hosts', 'runs-on', 'backs-up-to')
|
||||||
|
ORDER BY CASE r.type
|
||||||
|
WHEN 'provides' THEN 0 WHEN 'runs-on' THEN 1
|
||||||
|
WHEN 'backs-up-to' THEN 2 ELSE 3 END`,
|
||||||
|
entityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var attrs map[string]any
|
||||||
|
if json.Unmarshal(raw, &attrs) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resolveHost(attrs) != "" {
|
||||||
|
return attrs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveHost(attrs map[string]any) string {
|
||||||
|
if attrs == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
if ip, ok := attrs["lan_ip"].(string); ok && ip != "" {
|
if ip, ok := attrs["lan_ip"].(string); ok && ip != "" {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
// public_ipv4 before mesh: the scheduler container has no mesh interface,
|
||||||
|
// so a standalone-server reachable only by mesh IP (netbird-vps) is
|
||||||
|
// unprobeable even though a public IPv4 is available.
|
||||||
|
if ip, ok := attrs["public_ipv4"].(string); ok && ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
if mesh, ok := attrs["mesh"].(map[string]any); ok {
|
if mesh, ok := attrs["mesh"].(map[string]any); ok {
|
||||||
if nb, ok := mesh["netbird"].(map[string]any); ok {
|
if nb, ok := mesh["netbird"].(map[string]any); ok {
|
||||||
if ip, ok := nb["ip"].(string); ok && ip != "" {
|
if ip, ok := nb["ip"].(string); ok && ip != "" {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
// Seeds record the mesh name, not an address — ws:mac-mini
|
||||||
|
// carries only `fqdn`, which is why it resolved to nothing.
|
||||||
|
if fqdn, ok := nb["fqdn"].(string); ok && fqdn != "" {
|
||||||
|
return fqdn
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ip, ok := attrs["mesh_ip"].(string); ok && ip != "" {
|
if ip, ok := attrs["mesh_ip"].(string); ok && ip != "" {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
for _, key := range []string{"host", "address", "public_host"} {
|
||||||
|
if v, ok := attrs[key].(string); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +544,13 @@ func resolveSSHUser(attrs map[string]any) string {
|
|||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Workstations carry their login as a top-level `user` attribute
|
||||||
|
// (mac-mini: user: dtoro) rather than under ssh.user. Take it only when
|
||||||
|
// no explicit ssh.user was set, so a host that genuinely wants root still
|
||||||
|
// gets root.
|
||||||
|
if u, ok := attrs["user"].(string); ok && u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
return "root"
|
return "root"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,148 +566,17 @@ func resolveSSHPort(attrs map[string]any) int {
|
|||||||
return 22
|
return 22
|
||||||
}
|
}
|
||||||
|
|
||||||
func ForEntityType(entityType string, attrs map[string]any) []CheckDef {
|
// LogResult emits the one line that was missing: a type that asked for
|
||||||
host := ResolveHost(attrs)
|
// monitoring and did not get it.
|
||||||
user := resolveSSHUser(attrs)
|
func LogResult(slug, entityType string, res Result) {
|
||||||
port := resolveSSHPort(attrs)
|
switch {
|
||||||
|
case res.Undeclared:
|
||||||
ssh := func(script string) CheckDef {
|
slog.Info("checkdefaults: type declares no monitoring",
|
||||||
return CheckDef{Kind: "ssh-script", Script: script, Host: host, User: user, Port: port}
|
"entity", slug, "type", entityType)
|
||||||
}
|
case len(res.Skipped) > 0:
|
||||||
|
for _, s := range res.Skipped {
|
||||||
switch entityType {
|
slog.Warn("checkdefaults: declared check not created",
|
||||||
case "proxmox-host", "standalone-server":
|
"entity", slug, "type", entityType, "kind", s.Kind, "reason", s.Reason)
|
||||||
if host == "" {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return []CheckDef{
|
|
||||||
{Kind: "ping", Host: host},
|
|
||||||
ssh("cpu_check.sh"),
|
|
||||||
ssh("memory_check.sh"),
|
|
||||||
ssh("load_check.sh"),
|
|
||||||
ssh("disk_usage_check.sh"),
|
|
||||||
ssh("updates_check.sh"),
|
|
||||||
}
|
|
||||||
case "workstation":
|
|
||||||
if host == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []CheckDef{
|
|
||||||
{Kind: "ping", Host: host},
|
|
||||||
ssh("cpu_check.sh"),
|
|
||||||
ssh("memory_check.sh"),
|
|
||||||
ssh("load_check.sh"),
|
|
||||||
}
|
|
||||||
case "lxc":
|
|
||||||
if host == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []CheckDef{
|
|
||||||
ssh("cpu_check.sh"),
|
|
||||||
ssh("memory_check.sh"),
|
|
||||||
ssh("load_check.sh"),
|
|
||||||
ssh("disk_usage_check.sh"),
|
|
||||||
}
|
|
||||||
case "vm":
|
|
||||||
if host == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []CheckDef{
|
|
||||||
{Kind: "ping", Host: host},
|
|
||||||
}
|
|
||||||
case "service":
|
|
||||||
if host == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
n, _ := attrs["name"].(string)
|
|
||||||
if n == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []CheckDef{
|
|
||||||
{Kind: "ssh-script", Script: "process_check.sh", Host: host, User: user, Port: port,
|
|
||||||
Extra: map[string]any{"args": n}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ShortSlug(slug string) string {
|
|
||||||
const n = 8
|
|
||||||
if len(slug) > n {
|
|
||||||
return slug[len(slug)-n:]
|
|
||||||
}
|
|
||||||
return slug
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultInterval(kind string) int32 {
|
|
||||||
switch kind {
|
|
||||||
case "ping":
|
|
||||||
return 30
|
|
||||||
case "ssh-script":
|
|
||||||
return 60
|
|
||||||
default:
|
|
||||||
return 300
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType string, attrsJSON []byte) {
|
|
||||||
_, _ = tx.Exec(ctx,
|
|
||||||
`INSERT INTO entity_status (entity_id, health, updated_at)
|
|
||||||
VALUES ($1, 'unknown', now())
|
|
||||||
ON CONFLICT (entity_id) DO NOTHING`,
|
|
||||||
entityID)
|
|
||||||
|
|
||||||
var attrs map[string]any
|
|
||||||
if len(attrsJSON) > 0 {
|
|
||||||
json.Unmarshal(attrsJSON, &attrs)
|
|
||||||
}
|
|
||||||
if attrs == nil {
|
|
||||||
attrs = map[string]any{}
|
|
||||||
}
|
|
||||||
|
|
||||||
defs := ForEntityType(entityType, attrs)
|
|
||||||
if len(defs) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, def := range defs {
|
|
||||||
checkID, err := uuid.NewV7()
|
|
||||||
if err != nil {
|
|
||||||
checkID = uuid.New()
|
|
||||||
}
|
|
||||||
checkSlug := fmt.Sprintf("check:%s:%s:%d", def.Kind, ShortSlug(slug), i)
|
|
||||||
|
|
||||||
_, _ = tx.Exec(ctx,
|
|
||||||
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, 'check', $2, 'active', '{}', 1, now(), now())
|
|
||||||
ON CONFLICT (slug) DO NOTHING`,
|
|
||||||
checkID, checkSlug)
|
|
||||||
|
|
||||||
configMap := map[string]any{}
|
|
||||||
if def.Script != "" {
|
|
||||||
configMap["script"] = def.Script
|
|
||||||
}
|
|
||||||
if def.Host != "" {
|
|
||||||
configMap["host"] = def.Host
|
|
||||||
}
|
|
||||||
if def.User != "" && def.User != "root" {
|
|
||||||
configMap["user"] = def.User
|
|
||||||
}
|
|
||||||
if def.Port != 0 && def.Port != 22 {
|
|
||||||
configMap["port"] = def.Port
|
|
||||||
}
|
|
||||||
if def.Thresholds != nil {
|
|
||||||
configMap["thresholds"] = def.Thresholds
|
|
||||||
}
|
|
||||||
for k, v := range def.Extra {
|
|
||||||
configMap[k] = v
|
|
||||||
}
|
|
||||||
configJSON, _ := json.Marshal(configMap)
|
|
||||||
|
|
||||||
_, _ = tx.Exec(ctx,
|
|
||||||
`INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, 30, true)
|
|
||||||
ON CONFLICT (entity_id) DO NOTHING`,
|
|
||||||
checkID, entityID, def.Kind, configJSON, DefaultInterval(def.Kind))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
125
internal/checkdefaults/defaults_test.go
Normal file
125
internal/checkdefaults/defaults_test.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package checkdefaults
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The attribute shapes here are copied from seeds/inventory.yaml. The original
|
||||||
|
// resolveHost looked for lan_ip / mesh.netbird.ip / mesh_ip, none of which a
|
||||||
|
// service or workstation actually carries — which is why 86 of 89 entities
|
||||||
|
// ended up with no checks.
|
||||||
|
func TestResolveHostAcceptsRealSeedShapes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
attrs map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"lxc carries lan_ip", map[string]any{"lan_ip": "192.168.8.246"}, "192.168.8.246"},
|
||||||
|
{
|
||||||
|
"ws:mac-mini carries only a netbird fqdn",
|
||||||
|
map[string]any{"mesh": map[string]any{"netbird": map[string]any{
|
||||||
|
"fqdn": "mac-mini-234-17.netbird.selfhosted"}}},
|
||||||
|
"mac-mini-234-17.netbird.selfhosted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a netbird ip still wins over the fqdn",
|
||||||
|
map[string]any{"mesh": map[string]any{"netbird": map[string]any{
|
||||||
|
"ip": "100.122.0.10", "fqdn": "x.netbird.selfhosted"}}},
|
||||||
|
"100.122.0.10",
|
||||||
|
},
|
||||||
|
{"public_host as a last resort", map[string]any{"public_host": "media.hubris.network"}, "media.hubris.network"},
|
||||||
|
{"a service carries no address at all", map[string]any{
|
||||||
|
"url": "https://media.hubris.network", "port": 8096}, ""},
|
||||||
|
{"nil attrs", nil, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := resolveHost(c.attrs); got != c.want {
|
||||||
|
t.Errorf("%s: resolveHost = %q, want %q", c.desc, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPURLPrefersAttributeThenName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
name string
|
||||||
|
attrs map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"explicit url wins", "jellyfin",
|
||||||
|
map[string]any{"url": "https://media.hubris.network"}, "https://media.hubris.network"},
|
||||||
|
{"public_host becomes https", "jellyfin",
|
||||||
|
map[string]any{"public_host": "media.hubris.network"}, "https://media.hubris.network"},
|
||||||
|
// Ingress routes carry the hostname as the entity name and usually
|
||||||
|
// declare no attributes at all.
|
||||||
|
{"hostname-shaped name", "media.hubris.network", nil, "https://media.hubris.network"},
|
||||||
|
{"a bare service name is not a hostname", "jellyfin", nil, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
got := httpURL(Target{Name: c.name}, c.attrs)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%s: httpURL = %q, want %q", c.desc, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildKindReportsWhyItSkipped(t *testing.T) {
|
||||||
|
// A declared kind that cannot be built must explain itself rather than
|
||||||
|
// vanish — that silence is what hid the coverage gap.
|
||||||
|
if defs, reason := buildKind(KindPing, Target{}, nil, "", "root", 22); len(defs) != 0 || reason == "" {
|
||||||
|
t.Errorf("ping without a host should skip with a reason, got %d defs / %q", len(defs), reason)
|
||||||
|
}
|
||||||
|
if defs, reason := buildKind(KindProcess, Target{Name: ""}, nil, "10.0.0.1", "root", 22); len(defs) != 0 || reason == "" {
|
||||||
|
t.Errorf("process without a name should skip with a reason, got %d defs / %q", len(defs), reason)
|
||||||
|
}
|
||||||
|
if defs, reason := buildKind("dns", Target{}, nil, "10.0.0.1", "root", 22); len(defs) != 0 || reason == "" {
|
||||||
|
t.Errorf("an unimplemented kind should skip with a reason, got %d defs / %q", len(defs), reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildKindProcessPassesTheUnitName(t *testing.T) {
|
||||||
|
// process_check.sh reads $1 and answers "no service name provided"
|
||||||
|
// without it. checkdefaults always wrote args; nothing read them.
|
||||||
|
defs, reason := buildKind(KindProcess, Target{Name: "jellyfin"}, nil, "10.0.0.1", "root", 22)
|
||||||
|
if len(defs) != 1 {
|
||||||
|
t.Fatalf("expected one process check, got %d (%s)", len(defs), reason)
|
||||||
|
}
|
||||||
|
if got := defs[0].config["args"]; got != "jellyfin" {
|
||||||
|
t.Errorf("process check args = %v, want jellyfin", got)
|
||||||
|
}
|
||||||
|
if got := defs[0].config["script"]; got != "process_check.sh" {
|
||||||
|
t.Errorf("process check script = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildKindHTTPUsesAStatusRangeNotAnExactCode(t *testing.T) {
|
||||||
|
// Most services sit behind Authentik and answer 302/401.
|
||||||
|
defs, _ := buildKind(KindHTTP, Target{Name: "jellyfin"},
|
||||||
|
map[string]any{"url": "https://media.hubris.network"}, "", "root", 22)
|
||||||
|
if len(defs) != 1 {
|
||||||
|
t.Fatalf("expected one http check, got %d", len(defs))
|
||||||
|
}
|
||||||
|
if got := defs[0].config["max_status"]; got != 500 {
|
||||||
|
t.Errorf("max_status = %v, want 500", got)
|
||||||
|
}
|
||||||
|
if _, exact := defs[0].config["expected_status"]; exact {
|
||||||
|
t.Error("default http checks must not pin an exact status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildKindResourceExpandsToFourScripts(t *testing.T) {
|
||||||
|
defs, _ := buildKind(KindResource, Target{}, nil, "10.0.0.1", "root", 22)
|
||||||
|
if len(defs) != 4 {
|
||||||
|
t.Fatalf("resource should expand to 4 checks, got %d", len(defs))
|
||||||
|
}
|
||||||
|
for _, d := range defs {
|
||||||
|
if d.kind != "ssh-script" {
|
||||||
|
t.Errorf("resource check kind = %q, want ssh-script", d.kind)
|
||||||
|
}
|
||||||
|
if d.config["host"] != "10.0.0.1" {
|
||||||
|
t.Errorf("resource check lost its host: %v", d.config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
internal/db/checks.go
Normal file
34
internal/db/checks.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/checkdefaults"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnsureEntityChecks derives an entity's default check_defs from the
|
||||||
|
// monitoring spec of its type (resolving per-entity `monitoring` overrides).
|
||||||
|
//
|
||||||
|
// This is the single shared hook that keeps the check graph in sync with
|
||||||
|
// entity mutations. Both the HTTP create/patch handlers and the MCP
|
||||||
|
// entity-mutation tools (create_entity, update_entity_attributes) call it so
|
||||||
|
// that flipping an entity's `monitoring` attribute regenerates checks
|
||||||
|
// regardless of which surface made the change — previously only the HTTP
|
||||||
|
// path ran check derivation, so entities mutated via MCP silently produced no
|
||||||
|
// checks (see plans/2026-08-03-session-review-haos-monitoring-capability-gaps.md, A2).
|
||||||
|
func EnsureEntityChecks(ctx context.Context, tx pgx.Tx, id uuid.UUID, slug, entityType, name string, attrs []byte) (checkdefaults.Result, error) {
|
||||||
|
tree, err := LoadTypeTree(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return checkdefaults.Result{}, err
|
||||||
|
}
|
||||||
|
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
|
||||||
|
ID: id, Slug: slug, Type: entityType, Name: name, Attrs: attrs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
checkdefaults.LogResult(slug, entityType, res)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -167,6 +167,62 @@ func TestSeedIngestIdempotentAndNoDuplicateEdges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: insertOneEntityType read tMap["attribute_schema"], but
|
||||||
|
// seeds/ontology.yaml spells the key `attributes:`. The mismatch marshalled a
|
||||||
|
// nil into the JSON literal `null` for every one of the 60 types, so no
|
||||||
|
// attribute schema was ever ingested — the API and `oikos export` returned
|
||||||
|
// null across the board, silently, for the life of the project.
|
||||||
|
func TestSeedIngestsAttributeSchemas(t *testing.T) {
|
||||||
|
pool := newTestPool(t)
|
||||||
|
seedAll(t, pool, seedsDir())
|
||||||
|
|
||||||
|
if n := count(t, pool,
|
||||||
|
`SELECT count(*) FROM entity_types WHERE attribute_schema = 'null'::jsonb`); n != 0 {
|
||||||
|
t.Errorf("%d entity types stored the JSON literal null instead of a schema or SQL NULL", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := count(t, pool,
|
||||||
|
`SELECT count(*) FROM entity_types WHERE jsonb_typeof(attribute_schema) = 'object'`); n == 0 {
|
||||||
|
t.Fatal("no entity type ingested an attribute schema")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A type declaring `attributes:` must round-trip its properties.
|
||||||
|
if n := count(t, pool, `SELECT count(*) FROM entity_types
|
||||||
|
WHERE name = 'lxc' AND attribute_schema #>> '{properties,pve_id,type}' = 'integer'`); n != 1 {
|
||||||
|
t.Error("lxc.attribute_schema lost its declared pve_id property")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A type declaring none stores SQL NULL, not a JSON null.
|
||||||
|
if n := count(t, pool, `SELECT count(*) FROM entity_types
|
||||||
|
WHERE name = 'sensor' AND attribute_schema IS NULL`); n != 1 {
|
||||||
|
t.Error("a type declaring no attributes should store SQL NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitoring_spec drives which entities coverageSweep may flag as unmonitored,
|
||||||
|
// so the three states have to survive ingest distinctly: SQL NULL (undeclared,
|
||||||
|
// resolved from an ancestor or the layer default), '[]' (explicitly
|
||||||
|
// unmonitorable), and a non-empty array (the kinds the type warrants).
|
||||||
|
func TestSeedIngestsMonitoringSpec(t *testing.T) {
|
||||||
|
pool := newTestPool(t)
|
||||||
|
seedAll(t, pool, seedsDir())
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
typ, where, desc string
|
||||||
|
}{
|
||||||
|
{"service", `monitoring_spec = '["http","process"]'::jsonb`, "declared kinds"},
|
||||||
|
{"machine", `monitoring_spec = '["ping","resource","updates"]'::jsonb`, "declared on an abstract type"},
|
||||||
|
{"site", `monitoring_spec = '[]'::jsonb`, "explicitly unmonitorable"},
|
||||||
|
{"lxc", `monitoring_spec IS NULL`, "inherits from container, so its own column is NULL"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if n := count(t, pool, fmt.Sprintf(
|
||||||
|
`SELECT count(*) FROM entity_types WHERE name = '%s' AND %s`, c.typ, c.where)); n != 1 {
|
||||||
|
t.Errorf("%s (%s): monitoring_spec did not match %s", c.typ, c.desc, c.where)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAbstractTypeRejected(t *testing.T) {
|
func TestAbstractTypeRejected(t *testing.T) {
|
||||||
pool := newTestPool(t)
|
pool := newTestPool(t)
|
||||||
seedAll(t, pool, seedsDir())
|
seedAll(t, pool, seedsDir())
|
||||||
@@ -250,7 +306,17 @@ func TestBlastRadiusTerminatesOnCycles(t *testing.T) {
|
|||||||
pool := newTestPool(t)
|
pool := newTestPool(t)
|
||||||
seedAll(t, pool, seedsDir())
|
seedAll(t, pool, seedsDir())
|
||||||
|
|
||||||
// Build a dependency cycle: gitea → caddy → authentik → gitea
|
// Build a dependency cycle: gitea → caddy → authentik → gitea.
|
||||||
|
//
|
||||||
|
// `depends-on` is declared blast_direction: backward — "A depends-on B"
|
||||||
|
// means B failing breaks A — so the blast radius of gitea walks the edges
|
||||||
|
// BACKWARDS: whoever depends on gitea is affected first. That is authentik
|
||||||
|
// (1 hop), then caddy which depends on authentik (2 hops).
|
||||||
|
//
|
||||||
|
// This test previously asserted caddy=1, authentik=2, which is the same
|
||||||
|
// cycle walked the wrong way round: blast_radius used to follow every edge
|
||||||
|
// source→target regardless of what the edge means, so it answered "what
|
||||||
|
// does gitea depend on" while being named for the opposite question.
|
||||||
cycle := []byte(`
|
cycle := []byte(`
|
||||||
version: 1
|
version: 1
|
||||||
relationships:
|
relationships:
|
||||||
@@ -286,14 +352,24 @@ relationships:
|
|||||||
}
|
}
|
||||||
got[slug] = depth
|
got[slug] = depth
|
||||||
}
|
}
|
||||||
want := map[string]int{"service:gitea": 0, "service:caddy": 1, "service:authentik": 2}
|
want := map[string]int{"service:gitea": 0, "service:authentik": 1, "service:caddy": 2}
|
||||||
for slug, depth := range want {
|
for slug, depth := range want {
|
||||||
if got[slug] != depth {
|
if got[slug] != depth {
|
||||||
t.Errorf("blast_radius[%s] = %d, want %d (full: %v)", slug, got[slug], depth, got)
|
t.Errorf("blast_radius[%s] = %d, want %d (full: %v)", slug, got[slug], depth, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(got) != len(want) {
|
// Deliberately not an exact node count. Walking the right way round also
|
||||||
t.Errorf("blast_radius returned %d nodes, want %d: %v", len(got), len(want), got)
|
// surfaces the real seed's own dependents of gitea (homelab-mcp and what
|
||||||
|
// depends on it), which are correct answers — the old exact-count
|
||||||
|
// assertion only held because the forward walk found nothing real.
|
||||||
|
// What matters here is that the cycle terminates rather than recursing.
|
||||||
|
if len(got) > 20 {
|
||||||
|
t.Errorf("blast_radius did not terminate sensibly: %d nodes: %v", len(got), got)
|
||||||
|
}
|
||||||
|
for slug, depth := range got {
|
||||||
|
if depth > 5 {
|
||||||
|
t.Errorf("blast_radius[%s] = %d, beyond the max_depth bound", slug, depth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
146
internal/db/lifecycle.go
Normal file
146
internal/db/lifecycle.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrTransitionInvalid is a sentinel returned by ValidateTransition when the
|
||||||
|
// from→to pair is not a declared lifecycle transition or a precondition fails.
|
||||||
|
// Callers test with errors.Is to distinguish semantic validation failures
|
||||||
|
// (→ HTTP 409) from infrastructure errors (→ HTTP 500).
|
||||||
|
var ErrTransitionInvalid = errors.New("invalid lifecycle transition")
|
||||||
|
|
||||||
|
// ValidateTransition enforces an entity type's lifecycle: fromState → toState
|
||||||
|
// must be a declared transition, and every precondition it lists must hold. A
|
||||||
|
// type with no lifecycle defined allows any state. A no-op (fromState ==
|
||||||
|
// toState) passes immediately.
|
||||||
|
//
|
||||||
|
// Shared by the HTTP PATCH path and the MCP set_entity_state tool so both
|
||||||
|
// surfaces apply identical lifecycle rules — previously only the HTTP path
|
||||||
|
// validated transitions, so an agent changing state via MCP could skip the
|
||||||
|
// graph's retire/deprecate guardrails entirely.
|
||||||
|
func ValidateTransition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, fromState, toState string) error {
|
||||||
|
if toState == fromState {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, entityType)
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil // no lifecycle defined → any state allowed
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var transitions map[string]map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
||||||
|
return fmt.Errorf("parse lifecycle transitions: %w", err)
|
||||||
|
}
|
||||||
|
tos, ok := transitions[fromState]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: no transitions defined from %q", ErrTransitionInvalid, fromState)
|
||||||
|
}
|
||||||
|
trans, ok := tos[toState]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: %s → %s is not a declared lifecycle transition", ErrTransitionInvalid, fromState, toState)
|
||||||
|
}
|
||||||
|
var gate struct {
|
||||||
|
Requires []string `json:"requires"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(trans, &gate); err == nil {
|
||||||
|
for _, check := range gate.Requires {
|
||||||
|
if err := checkPrecondition(ctx, tx, entityID, entityType, check); err != nil {
|
||||||
|
return fmt.Errorf("%w: precondition %q not met: %w", ErrTransitionInvalid, check, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPrecondition evaluates one mechanical precondition named by a lifecycle
|
||||||
|
// transition's `requires` list. Soft/operator-confirmed checks pass; unknown
|
||||||
|
// checks are skipped (operator intent overrides). Moved here from httpapi so
|
||||||
|
// both surfaces share one implementation.
|
||||||
|
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
|
||||||
|
switch check {
|
||||||
|
case "no-inbound-edges":
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return fmt.Errorf("%d inbound relationship edges remaining", count)
|
||||||
|
}
|
||||||
|
case "backups-verified", "secrets-revoked", "ingress-dns-removed":
|
||||||
|
var attrs string
|
||||||
|
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"backups-verified": "backups_verified",
|
||||||
|
"secrets-revoked": "secrets_revoked",
|
||||||
|
"ingress-dns-removed": "ingress_dns_removed",
|
||||||
|
}[check]
|
||||||
|
if !strings.Contains(attrs, want) {
|
||||||
|
return fmt.Errorf("%s not recorded in entity attributes", want)
|
||||||
|
}
|
||||||
|
case "age-key-enrolled-if-needed":
|
||||||
|
if entityType == "workstation" {
|
||||||
|
var attrs string
|
||||||
|
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.Contains(attrs, "age_pubkey") {
|
||||||
|
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "mesh-joined-if-needed":
|
||||||
|
if entityType == "workstation" {
|
||||||
|
var attrs string
|
||||||
|
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.Contains(attrs, "mesh_ip") {
|
||||||
|
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "health-check-answering":
|
||||||
|
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
|
||||||
|
if err != nil || st.Health == "unknown" || st.Health == "down" {
|
||||||
|
h := "unknown"
|
||||||
|
if err == nil {
|
||||||
|
h = st.Health
|
||||||
|
}
|
||||||
|
return fmt.Errorf("health check not answering (status: %s)", h)
|
||||||
|
}
|
||||||
|
case "doc-page-complete":
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM relationships r
|
||||||
|
JOIN entities ke ON ke.id = r.source_id
|
||||||
|
WHERE r.target_id = $1 AND r.valid_to IS NULL
|
||||||
|
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
|
||||||
|
entityID).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return fmt.Errorf("no documentation linked to entity")
|
||||||
|
}
|
||||||
|
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
|
||||||
|
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
|
||||||
|
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
|
||||||
|
"ingress-live-if-public", "doc-page-stub", "un-deprecate-note", "write-off-note":
|
||||||
|
// Soft checks — always pass. Operator-confirmed via the transition
|
||||||
|
// request itself, or not mechanically enforceable.
|
||||||
|
default:
|
||||||
|
// Unknown preconditions are skipped (operator intent overrides).
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -30,8 +25,8 @@ ON CONFLICT (actor, key) DO NOTHING;
|
|||||||
|
|
||||||
-- name: InsertAuditEntry :exec
|
-- name: InsertAuditEntry :exec
|
||||||
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
|
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
|
||||||
status_code, detail, source_ip, correlation_id)
|
status_code, detail, source_ip, correlation_id, session_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
|
||||||
|
|
||||||
-- name: InsertEvent :one
|
-- 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)
|
||||||
@@ -60,12 +55,36 @@ FROM events WHERE id > $1 ORDER BY id ASC LIMIT $2;
|
|||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
-- name: ListEnabledCheckDefs :many
|
-- name: ListEnabledCheckDefs :many
|
||||||
|
-- Enabled AND due. interval_s used to be selected but never filtered on, so
|
||||||
|
-- every check ran on every 30s pass and the declared intervals meant nothing.
|
||||||
|
-- NULL last_run_at = never run = due now.
|
||||||
SELECT cd.entity_id, cd.target_id, cd.target_type, cd.kind, cd.config,
|
SELECT cd.entity_id, cd.target_id, cd.target_type, cd.kind, cd.config,
|
||||||
cd.interval_s, cd.timeout_s, cd.zone, cd.enabled, cd.updated_at,
|
cd.interval_s, cd.timeout_s, cd.zone, cd.enabled, cd.updated_at,
|
||||||
e.slug AS entity_slug
|
e.slug AS entity_slug
|
||||||
FROM check_defs cd
|
FROM check_defs cd
|
||||||
JOIN entities e ON e.id = cd.entity_id
|
JOIN entities e ON e.id = cd.entity_id
|
||||||
WHERE cd.enabled = true;
|
LEFT JOIN entities tgt ON tgt.id = cd.target_id
|
||||||
|
WHERE cd.enabled = true
|
||||||
|
AND (tgt.id IS NULL OR tgt.state IS NULL OR tgt.state NOT IN ('deprecated', 'destroyed'))
|
||||||
|
AND (cd.last_run_at IS NULL
|
||||||
|
OR cd.last_run_at <= now() - make_interval(secs => cd.interval_s));
|
||||||
|
|
||||||
|
-- name: MarkCheckRun :exec
|
||||||
|
UPDATE check_defs SET last_run_at = now(), last_health = $2 WHERE entity_id = $1;
|
||||||
|
|
||||||
|
-- name: WorstHealthForTarget :one
|
||||||
|
-- An entity is as healthy as its unhealthiest check. Checks that have not run
|
||||||
|
-- yet (last_health IS NULL) are ignored rather than counted as unknown, so a
|
||||||
|
-- newly added check does not drag a known-good entity down before it has
|
||||||
|
-- produced a verdict.
|
||||||
|
SELECT COALESCE(
|
||||||
|
(SELECT last_health FROM check_defs
|
||||||
|
WHERE enabled AND target_id = $1 AND last_health IS NOT NULL
|
||||||
|
ORDER BY CASE last_health
|
||||||
|
WHEN 'down' THEN 0 WHEN 'degraded' THEN 1 WHEN 'stale' THEN 2
|
||||||
|
WHEN 'unknown' THEN 3 ELSE 4 END
|
||||||
|
LIMIT 1),
|
||||||
|
'unknown')::text AS health;
|
||||||
|
|
||||||
-- name: GetCheckDef :one
|
-- name: GetCheckDef :one
|
||||||
SELECT * FROM check_defs WHERE entity_id = $1;
|
SELECT * FROM check_defs WHERE entity_id = $1;
|
||||||
@@ -89,9 +108,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 +122,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 +163,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 +206,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;
|
||||||
|
|||||||
@@ -13,14 +13,15 @@ import (
|
|||||||
|
|
||||||
// SeedResult holds counts from a seed ingest operation.
|
// SeedResult holds counts from a seed ingest operation.
|
||||||
type SeedResult struct {
|
type SeedResult struct {
|
||||||
Lifecycles int
|
Lifecycles int
|
||||||
EntityTypes int
|
EntityTypes int
|
||||||
RelationshipTypes int
|
RelationshipTypes int
|
||||||
Entities int
|
Entities int
|
||||||
Relationships int
|
Relationships int
|
||||||
RiskClasses int
|
RiskClasses int
|
||||||
ApprovalRules int
|
ApprovalRules int
|
||||||
AutonomySettings int
|
AutonomySettings int
|
||||||
|
Checks int
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestOntologySeed ingests seeds/ontology.yaml into the DB.
|
// IngestOntologySeed ingests seeds/ontology.yaml into the DB.
|
||||||
@@ -66,12 +67,20 @@ func IngestOntologySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*S
|
|||||||
targetType, _ := rtMap["target"].(string)
|
targetType, _ := rtMap["target"].(string)
|
||||||
cardinality, _ := rtMap["cardinality"].(string)
|
cardinality, _ := rtMap["cardinality"].(string)
|
||||||
desc, _ := rtMap["description"].(string)
|
desc, _ := rtMap["description"].(string)
|
||||||
|
// Which end of the edge depends on the other; drives blast_radius().
|
||||||
|
// Absent means 'none' — an undeclared edge contributes nothing rather
|
||||||
|
// than silently producing a wrong dependency answer.
|
||||||
|
blastDirection, _ := rtMap["blast_direction"].(string)
|
||||||
|
if blastDirection == "" {
|
||||||
|
blastDirection = "none"
|
||||||
|
}
|
||||||
_, err := tx.Exec(ctx,
|
_, err := tx.Exec(ctx,
|
||||||
`INSERT INTO relationship_types (name, inverse, source_type, target_type, cardinality, description)
|
`INSERT INTO relationship_types (name, inverse, source_type, target_type, cardinality, description, blast_direction)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (name) DO UPDATE SET inverse = $2, source_type = $3,
|
ON CONFLICT (name) DO UPDATE SET inverse = $2, source_type = $3,
|
||||||
target_type = $4, cardinality = $5, description = $6`,
|
target_type = $4, cardinality = $5, description = $6,
|
||||||
name, nullableStr(inverse), sourceType, targetType, cardinality, desc)
|
blast_direction = $7`,
|
||||||
|
name, nullableStr(inverse), sourceType, targetType, cardinality, desc, blastDirection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("relationship_type %s: %w", name, err)
|
return nil, fmt.Errorf("relationship_type %s: %w", name, err)
|
||||||
}
|
}
|
||||||
@@ -96,6 +105,7 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
|||||||
// Entities
|
// Entities
|
||||||
entities, _ := data["entities"].([]any)
|
entities, _ := data["entities"].([]any)
|
||||||
entityTypes := make(map[string]string) // slug -> type, for edge validation
|
entityTypes := make(map[string]string) // slug -> type, for edge validation
|
||||||
|
var pendingChecks []checkdefaults.Target
|
||||||
for _, raw := range entities {
|
for _, raw := range entities {
|
||||||
eMap, ok := raw.(map[string]any)
|
eMap, ok := raw.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -144,7 +154,12 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
|||||||
return nil, fmt.Errorf("entity_status %s: %w", slug, err)
|
return nil, fmt.Errorf("entity_status %s: %w", slug, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
checkdefaults.Ensure(ctx, tx, entityID, slug, typeName, attrsBytes)
|
// Default checks are deferred until after relationships are ingested:
|
||||||
|
// a service has no address of its own and inherits its container's,
|
||||||
|
// which means the hosting edge has to exist first.
|
||||||
|
pendingChecks = append(pendingChecks, checkdefaults.Target{
|
||||||
|
ID: entityID, Slug: slug, Type: typeName, Name: name, Attrs: attrsBytes,
|
||||||
|
})
|
||||||
|
|
||||||
r.Entities++
|
r.Entities++
|
||||||
}
|
}
|
||||||
@@ -208,6 +223,19 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default checks, now that hosting edges exist. Errors here are fatal:
|
||||||
|
// swallowing them is what let a foreign-key violation abort the ingest
|
||||||
|
// transaction while surfacing as an unrelated failure several entities
|
||||||
|
// later.
|
||||||
|
for _, target := range pendingChecks {
|
||||||
|
res, err := checkdefaults.Ensure(ctx, tx, tree, target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("default checks for %s: %w", target.Slug, err)
|
||||||
|
}
|
||||||
|
checkdefaults.LogResult(target.Slug, target.Type, res)
|
||||||
|
r.Checks += res.Created
|
||||||
|
}
|
||||||
|
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,19 +389,68 @@ func insertOneEntityType(ctx context.Context, tx pgx.Tx, name string, tMap map[s
|
|||||||
layer, _ := tMap["layer"].(string)
|
layer, _ := tMap["layer"].(string)
|
||||||
desc, _ := tMap["description"].(string)
|
desc, _ := tMap["description"].(string)
|
||||||
lifecycleID, _ := tMap["lifecycle"].(string)
|
lifecycleID, _ := tMap["lifecycle"].(string)
|
||||||
attrSchema := tMap["attribute_schema"]
|
|
||||||
|
|
||||||
schemaBytes, _ := json.Marshal(attrSchema)
|
// seeds/ontology.yaml spells this `attributes:`. Reading it as
|
||||||
|
// "attribute_schema" silently marshalled nil to the JSON literal `null`
|
||||||
|
// for every type, so no attribute schema was ever ingested — the API and
|
||||||
|
// `oikos export` returned null for all 60 types.
|
||||||
|
attrSchema := tMap["attributes"]
|
||||||
_, err := tx.Exec(ctx,
|
_, err := tx.Exec(ctx,
|
||||||
`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description,
|
`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description,
|
||||||
lifecycle_id, attribute_schema, schema_version, status, created_at, updated_at)
|
lifecycle_id, attribute_schema, monitoring_spec, schema_version, status, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, 'active', now(), now())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 1, 'active', now(), now())
|
||||||
ON CONFLICT (name) DO UPDATE SET parent_type = $2, is_abstract = $3, domain = $4,
|
ON CONFLICT (name) DO UPDATE SET parent_type = $2, is_abstract = $3, domain = $4,
|
||||||
layer = $5, description = $6, lifecycle_id = $7, attribute_schema = $8, updated_at = now()`,
|
layer = $5, description = $6, lifecycle_id = $7, attribute_schema = $8,
|
||||||
name, nullableStr(parent), isAbstract, domain, layer, desc, nullableStr(lifecycleID), nullableStr(string(schemaBytes)))
|
monitoring_spec = $9, updated_at = now()`,
|
||||||
|
name, nullableStr(parent), isAbstract, domain, layer, desc, nullableStr(lifecycleID),
|
||||||
|
attributeSchemaJSON(attrSchema), monitoringSpecJSON(tMap["monitoring"]))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attributeSchemaJSON marshals a type's `attributes:` block for storage,
|
||||||
|
// mapping "the type declares no schema" to SQL NULL rather than to the JSON
|
||||||
|
// literal `null`. Both readers already treat a JSON `null` as absent, but a
|
||||||
|
// real NULL is what `attribute_schema IS NULL` expects and is what the column
|
||||||
|
// meant all along.
|
||||||
|
func attributeSchemaJSON(v any) any {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitoringSpecJSON normalises an entity type's `monitoring:` declaration into
|
||||||
|
// the JSONB stored in entity_types.monitoring_spec. Three outcomes, and the
|
||||||
|
// difference between the last two is load-bearing for coverage signalling:
|
||||||
|
//
|
||||||
|
// absent → nil (SQL NULL) — undeclared, an ontology gap
|
||||||
|
// none | [] → "[]" — explicitly unmonitorable, by design
|
||||||
|
// [http, resource]→ '["http","resource"]'
|
||||||
|
//
|
||||||
|
// `monitoring: none` is accepted as a more legible spelling of `[]`; YAML
|
||||||
|
// parses the bare word as the string "none", not as null.
|
||||||
|
func monitoringSpecJSON(v any) any {
|
||||||
|
switch spec := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
case string:
|
||||||
|
if spec == "none" {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
// A single kind written unquoted, e.g. `monitoring: http`.
|
||||||
|
b, _ := json.Marshal([]string{spec})
|
||||||
|
return string(b)
|
||||||
|
case []any:
|
||||||
|
b, _ := json.Marshal(toStringSlice(spec))
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func toStringSlice(v any) []string {
|
func toStringSlice(v any) []string {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -407,4 +484,3 @@ func keysOf(m map[string]map[string]any) []string {
|
|||||||
}
|
}
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -35,11 +35,19 @@ type AgentMessage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentSession struct {
|
type AgentSession struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Title string
|
Title string
|
||||||
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
|
||||||
|
Blocker string
|
||||||
|
ClosedAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Approval struct {
|
type Approval struct {
|
||||||
@@ -83,6 +91,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 {
|
||||||
@@ -103,6 +112,10 @@ type CheckDef struct {
|
|||||||
Zone *string
|
Zone *string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
// When this check last executed. NULL = never, due immediately. Compared against interval_s to decide due-ness.
|
||||||
|
LastRunAt *time.Time
|
||||||
|
// This check's own most recent verdict (healthy/degraded/down/unknown). entity_status.health is the worst of these across the target's enabled checks.
|
||||||
|
LastHealth *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Classification struct {
|
type Classification struct {
|
||||||
@@ -170,6 +183,8 @@ type EntityType struct {
|
|||||||
Status string
|
Status string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
// Check kinds this type warrants, resolved through parent_type. NULL means undeclared (an ontology gap), [] means explicitly unmonitorable, ["http","resource"] means declared kinds. Populated from seeds/ontology.yaml.
|
||||||
|
MonitoringSpec []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
@@ -204,6 +219,14 @@ type Execution struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExecutionLog struct {
|
||||||
|
ExecutionID uuid.UUID
|
||||||
|
Ts time.Time
|
||||||
|
Seq int32
|
||||||
|
Stream string
|
||||||
|
Chunk string
|
||||||
|
}
|
||||||
|
|
||||||
type Feedback struct {
|
type Feedback struct {
|
||||||
EntityID uuid.UUID
|
EntityID uuid.UUID
|
||||||
ExecutionID uuid.UUID
|
ExecutionID uuid.UUID
|
||||||
@@ -234,6 +257,20 @@ type KnowledgeEntity struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
ContentHash *string
|
ContentHash *string
|
||||||
Search interface{}
|
Search interface{}
|
||||||
|
EditedBy string
|
||||||
|
DeletedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeRevision struct {
|
||||||
|
ID int64
|
||||||
|
EntityID uuid.UUID
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
Source *string
|
||||||
|
Tags []string
|
||||||
|
EditedBy string
|
||||||
|
VersionAt time.Time
|
||||||
|
RevisedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Ledger struct {
|
type Ledger struct {
|
||||||
@@ -289,6 +326,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
|
||||||
@@ -336,6 +380,8 @@ type RelationshipType struct {
|
|||||||
Cardinality string
|
Cardinality string
|
||||||
Description *string
|
Description *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
// Which end of this edge depends on the other. forward = target depends on source. backward = source depends on target. none = no runtime dependency. Drives blast_radius().
|
||||||
|
BlastDirection string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RiskClass struct {
|
type RiskClass struct {
|
||||||
@@ -351,6 +397,33 @@ 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
|
||||||
|
ReplacedReason *string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func (q *Queries) GetLifecycleForType(ctx context.Context, name string) (Lifecyc
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listEntityTypes = `-- name: ListEntityTypes :many
|
const listEntityTypes = `-- name: ListEntityTypes :many
|
||||||
SELECT name, parent_type, is_abstract, domain, layer, description, lifecycle_id, attribute_schema, schema_version, status, created_at, updated_at FROM entity_types ORDER BY name
|
SELECT name, parent_type, is_abstract, domain, layer, description, lifecycle_id, attribute_schema, schema_version, status, created_at, updated_at, monitoring_spec FROM entity_types ORDER BY name
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListEntityTypes(ctx context.Context) ([]EntityType, error) {
|
func (q *Queries) ListEntityTypes(ctx context.Context) ([]EntityType, error) {
|
||||||
@@ -55,6 +55,7 @@ func (q *Queries) ListEntityTypes(ctx context.Context) ([]EntityType, error) {
|
|||||||
&i.Status,
|
&i.Status,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.MonitoringSpec,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@ func (q *Queries) ListLifecycleDefs(ctx context.Context) ([]LifecycleDef, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRelationshipTypes = `-- name: ListRelationshipTypes :many
|
const listRelationshipTypes = `-- name: ListRelationshipTypes :many
|
||||||
SELECT name, inverse, source_type, target_type, cardinality, description, created_at FROM relationship_types ORDER BY name
|
SELECT name, inverse, source_type, target_type, cardinality, description, created_at, blast_direction FROM relationship_types ORDER BY name
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType, error) {
|
func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType, error) {
|
||||||
@@ -118,6 +119,7 @@ func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType
|
|||||||
&i.Cardinality,
|
&i.Cardinality,
|
||||||
&i.Description,
|
&i.Description,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.BlastDirection,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (q *Queries) GetAutonomySetting(ctx context.Context, key string) (string, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getCheckDef = `-- name: GetCheckDef :one
|
const getCheckDef = `-- name: GetCheckDef :one
|
||||||
SELECT entity_id, target_id, target_type, kind, config, interval_s, timeout_s, zone, enabled, updated_at FROM check_defs WHERE entity_id = $1
|
SELECT entity_id, target_id, target_type, kind, config, interval_s, timeout_s, zone, enabled, updated_at, last_run_at, last_health FROM check_defs WHERE entity_id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetCheckDef(ctx context.Context, entityID uuid.UUID) (CheckDef, error) {
|
func (q *Queries) GetCheckDef(ctx context.Context, entityID uuid.UUID) (CheckDef, error) {
|
||||||
@@ -68,6 +68,8 @@ func (q *Queries) GetCheckDef(ctx context.Context, entityID uuid.UUID) (CheckDef
|
|||||||
&i.Zone,
|
&i.Zone,
|
||||||
&i.Enabled,
|
&i.Enabled,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.LastRunAt,
|
||||||
|
&i.LastHealth,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -351,8 +353,8 @@ func (q *Queries) InsertApproval(ctx context.Context, arg InsertApprovalParams)
|
|||||||
|
|
||||||
const insertAuditEntry = `-- name: InsertAuditEntry :exec
|
const insertAuditEntry = `-- name: InsertAuditEntry :exec
|
||||||
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
|
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
|
||||||
status_code, detail, source_ip, correlation_id)
|
status_code, detail, source_ip, correlation_id, session_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertAuditEntryParams struct {
|
type InsertAuditEntryParams struct {
|
||||||
@@ -366,6 +368,7 @@ type InsertAuditEntryParams struct {
|
|||||||
Detail []byte
|
Detail []byte
|
||||||
SourceIp *string
|
SourceIp *string
|
||||||
CorrelationID *string
|
CorrelationID *string
|
||||||
|
SessionID *uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertAuditEntry(ctx context.Context, arg InsertAuditEntryParams) error {
|
func (q *Queries) InsertAuditEntry(ctx context.Context, arg InsertAuditEntryParams) error {
|
||||||
@@ -380,6 +383,7 @@ func (q *Queries) InsertAuditEntry(ctx context.Context, arg InsertAuditEntryPara
|
|||||||
arg.Detail,
|
arg.Detail,
|
||||||
arg.SourceIp,
|
arg.SourceIp,
|
||||||
arg.CorrelationID,
|
arg.CorrelationID,
|
||||||
|
arg.SessionID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -416,48 +420,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 +492,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 +514,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
|
||||||
`
|
`
|
||||||
@@ -800,7 +698,11 @@ SELECT cd.entity_id, cd.target_id, cd.target_type, cd.kind, cd.config,
|
|||||||
e.slug AS entity_slug
|
e.slug AS entity_slug
|
||||||
FROM check_defs cd
|
FROM check_defs cd
|
||||||
JOIN entities e ON e.id = cd.entity_id
|
JOIN entities e ON e.id = cd.entity_id
|
||||||
|
LEFT JOIN entities tgt ON tgt.id = cd.target_id
|
||||||
WHERE cd.enabled = true
|
WHERE cd.enabled = true
|
||||||
|
AND (tgt.id IS NULL OR tgt.state IS NULL OR tgt.state NOT IN ('deprecated', 'destroyed'))
|
||||||
|
AND (cd.last_run_at IS NULL
|
||||||
|
OR cd.last_run_at <= now() - make_interval(secs => cd.interval_s))
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListEnabledCheckDefsRow struct {
|
type ListEnabledCheckDefsRow struct {
|
||||||
@@ -820,6 +722,9 @@ type ListEnabledCheckDefsRow struct {
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Phase 3 queries
|
// Phase 3 queries
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
// Enabled AND due. interval_s used to be selected but never filtered on, so
|
||||||
|
// every check ran on every 30s pass and the declared intervals meant nothing.
|
||||||
|
// NULL last_run_at = never run = due now.
|
||||||
func (q *Queries) ListEnabledCheckDefs(ctx context.Context) ([]ListEnabledCheckDefsRow, error) {
|
func (q *Queries) ListEnabledCheckDefs(ctx context.Context) ([]ListEnabledCheckDefsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listEnabledCheckDefs)
|
rows, err := q.db.Query(ctx, listEnabledCheckDefs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -852,44 +757,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
|
||||||
@@ -1270,6 +1137,20 @@ func (q *Queries) ListSkills(ctx context.Context, status *string) ([]Skill, erro
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markCheckRun = `-- name: MarkCheckRun :exec
|
||||||
|
UPDATE check_defs SET last_run_at = now(), last_health = $2 WHERE entity_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type MarkCheckRunParams struct {
|
||||||
|
EntityID uuid.UUID
|
||||||
|
LastHealth *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) MarkCheckRun(ctx context.Context, arg MarkCheckRunParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, markCheckRun, arg.EntityID, arg.LastHealth)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const putIdempotentResponse = `-- name: PutIdempotentResponse :exec
|
const putIdempotentResponse = `-- name: PutIdempotentResponse :exec
|
||||||
INSERT INTO idempotency_keys (actor, key, request_hash, response_code, response_body)
|
INSERT INTO idempotency_keys (actor, key, request_hash, response_code, response_body)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
@@ -1462,20 +1343,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
|
||||||
`
|
`
|
||||||
@@ -1605,3 +1472,25 @@ func (q *Queries) UpsertSignal(ctx context.Context, arg UpsertSignalParams) (Sig
|
|||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worstHealthForTarget = `-- name: WorstHealthForTarget :one
|
||||||
|
SELECT COALESCE(
|
||||||
|
(SELECT last_health FROM check_defs
|
||||||
|
WHERE enabled AND target_id = $1 AND last_health IS NOT NULL
|
||||||
|
ORDER BY CASE last_health
|
||||||
|
WHEN 'down' THEN 0 WHEN 'degraded' THEN 1 WHEN 'stale' THEN 2
|
||||||
|
WHEN 'unknown' THEN 3 ELSE 4 END
|
||||||
|
LIMIT 1),
|
||||||
|
'unknown')::text AS health
|
||||||
|
`
|
||||||
|
|
||||||
|
// An entity is as healthy as its unhealthiest check. Checks that have not run
|
||||||
|
// yet (last_health IS NULL) are ignored rather than counted as unknown, so a
|
||||||
|
// newly added check does not drag a known-good entity down before it has
|
||||||
|
// produced a verdict.
|
||||||
|
func (q *Queries) WorstHealthForTarget(ctx context.Context, targetID *uuid.UUID) (string, error) {
|
||||||
|
row := q.db.QueryRow(ctx, worstHealthForTarget, targetID)
|
||||||
|
var health string
|
||||||
|
err := row.Scan(&health)
|
||||||
|
return health, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ func LoadTypeTree(ctx context.Context, tx pgx.Tx) (*ontology.TypeTree, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := tx.Query(ctx,
|
rows, err := tx.Query(ctx,
|
||||||
`SELECT name, COALESCE(parent_type,''), is_abstract, COALESCE(lifecycle_id,'')
|
`SELECT name, COALESCE(parent_type,''), is_abstract, COALESCE(lifecycle_id,''),
|
||||||
|
layer, monitoring_spec
|
||||||
FROM entity_types`)
|
FROM entity_types`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load entity_types: %w", err)
|
return nil, fmt.Errorf("load entity_types: %w", err)
|
||||||
@@ -27,10 +28,16 @@ func LoadTypeTree(ctx context.Context, tx pgx.Tx) (*ontology.TypeTree, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var name string
|
||||||
var info ontology.TypeInfo
|
var info ontology.TypeInfo
|
||||||
if err := rows.Scan(&name, &info.Parent, &info.IsAbstract, &info.LifecycleID); err != nil {
|
// NULL monitoring_spec means the type declared nothing; '[]' means it
|
||||||
|
// declared "explicitly unmonitorable". Scanning through a pointer is
|
||||||
|
// what keeps those two apart — see ontology.TypeTree.Monitoring.
|
||||||
|
var monitoring *[]string
|
||||||
|
if err := rows.Scan(&name, &info.Parent, &info.IsAbstract, &info.LifecycleID,
|
||||||
|
&info.Layer, &monitoring); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
info.Monitoring = monitoring
|
||||||
t.Types[name] = info
|
t.Types[name] = info
|
||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
internal/execlog/execlog.go
Normal file
135
internal/execlog/execlog.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Package execlog persists incremental command output for an execution and
|
||||||
|
// announces it on the event stream.
|
||||||
|
//
|
||||||
|
// It exists as its own package because both SSH execution paths need it —
|
||||||
|
// internal/mcp (the agent's auto-run windows) and internal/httpapi (the
|
||||||
|
// post-approval actuator). Those two already carry near-identical copies of
|
||||||
|
// sshExec, and every bug found in this area so far has been a case of the two
|
||||||
|
// copies drifting apart; one shared sink is the cheap way not to repeat that.
|
||||||
|
package execlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventInterval throttles execution.output events. Chunks are persisted as
|
||||||
|
// they arrive, but a chatty command (apt, a long build) can produce hundreds
|
||||||
|
// per second and the SSE broker drops events for slow subscribers — flooding
|
||||||
|
// it would push out the signal.* and approval.* events that actually need to
|
||||||
|
// arrive. The event is only a "there is more output" ping; subscribers re-read
|
||||||
|
// the rows.
|
||||||
|
const eventInterval = time.Second
|
||||||
|
|
||||||
|
// Sink receives output chunks as they arrive from a remote command.
|
||||||
|
type Sink func(stream string, chunk []byte)
|
||||||
|
|
||||||
|
// New returns a Sink that writes chunks to execution_logs and emits a
|
||||||
|
// throttled execution.output event, plus a Flush to call when the command
|
||||||
|
// finishes.
|
||||||
|
//
|
||||||
|
// The returned Sink is safe for concurrent use: stdout and stderr are written
|
||||||
|
// from separate goroutines.
|
||||||
|
func New(ctx context.Context, pool *db.Pool, execID uuid.UUID, correlationID string) (Sink, func()) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
seq int
|
||||||
|
lastEvent time.Time
|
||||||
|
pending bool
|
||||||
|
)
|
||||||
|
|
||||||
|
emit := func() {
|
||||||
|
if err := observability.Event(ctx, sqlcgen.New(pool), "execution.output", &execID,
|
||||||
|
"info", "actuator", correlationID, map[string]any{"execution_id": execID.String()}); err != nil {
|
||||||
|
slog.Debug("execlog: emit output event", "error", err, "execution_id", execID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sink := func(stream string, chunk []byte) {
|
||||||
|
if len(chunk) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
seq++
|
||||||
|
n := seq
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
// A failed log write must never fail the command: this is observability,
|
||||||
|
// and the authoritative output still lands in executions.result at the
|
||||||
|
// end. Log and carry on.
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO execution_logs (execution_id, seq, stream, chunk)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
execID, n, stream, string(chunk)); err != nil {
|
||||||
|
slog.Debug("execlog: persist chunk", "error", err, "execution_id", execID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
due := time.Since(lastEvent) >= eventInterval
|
||||||
|
if due {
|
||||||
|
lastEvent = time.Now()
|
||||||
|
pending = false
|
||||||
|
} else {
|
||||||
|
pending = true
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if due {
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush emits a final event when output arrived inside the throttle window,
|
||||||
|
// so the last few lines of a short command are not left unannounced.
|
||||||
|
flush := func() {
|
||||||
|
mu.Lock()
|
||||||
|
due := pending
|
||||||
|
pending = false
|
||||||
|
mu.Unlock()
|
||||||
|
if due {
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sink, flush
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns an execution's persisted output in order.
|
||||||
|
func Read(ctx context.Context, pool *db.Pool, execID uuid.UUID, limit int) ([]Chunk, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 1000
|
||||||
|
}
|
||||||
|
rows, err := pool.Query(ctx,
|
||||||
|
`SELECT seq, stream, chunk, ts FROM execution_logs
|
||||||
|
WHERE execution_id = $1 ORDER BY seq LIMIT $2`, execID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Chunk
|
||||||
|
for rows.Next() {
|
||||||
|
var c Chunk
|
||||||
|
if err := rows.Scan(&c.Seq, &c.Stream, &c.Chunk, &c.TS); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk is one persisted slice of command output.
|
||||||
|
type Chunk struct {
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
Stream string `json:"stream"`
|
||||||
|
Chunk string `json:"chunk"`
|
||||||
|
TS time.Time `json:"ts"`
|
||||||
|
}
|
||||||
751
internal/httpapi/actuator.go
Normal file
751
internal/httpapi/actuator.go
Normal file
@@ -0,0 +1,751 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/dtoro/oikos/internal/execlog"
|
||||||
|
"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
|
||||||
|
|
||||||
|
// streamWriter buffers everything it is given while forwarding each write to a
|
||||||
|
// sink. One on session.Stdout and another sharing the same buffer on
|
||||||
|
// session.Stderr reproduces CombinedOutput's interleaving in the order the
|
||||||
|
// remote end produced it. Mirrors the twin in internal/mcp/server.go.
|
||||||
|
type streamWriter struct {
|
||||||
|
mu *sync.Mutex
|
||||||
|
buf *bytes.Buffer
|
||||||
|
stream string
|
||||||
|
sink execlog.Sink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *streamWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.buf.Write(p)
|
||||||
|
w.mu.Unlock()
|
||||||
|
if w.sink != nil {
|
||||||
|
// Copy: the ssh library reuses p once Write returns.
|
||||||
|
w.sink(w.stream, append([]byte(nil), p...))
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshExec(ctx context.Context, host, user, command string) (string, error) {
|
||||||
|
return sshExecStream(ctx, host, user, command, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshExecStream runs a command and reports its combined output, forwarding
|
||||||
|
// each chunk to sink as it arrives. A nil sink behaves exactly as before.
|
||||||
|
func sshExecStream(ctx context.Context, host, user, command string, sink execlog.Sink) (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()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
)
|
||||||
|
session.Stdout = &streamWriter{mu: &mu, buf: &buf, stream: "stdout", sink: sink}
|
||||||
|
session.Stderr = &streamWriter{mu: &mu, buf: &buf, stream: "stderr", sink: sink}
|
||||||
|
|
||||||
|
collected := func() string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return strings.TrimSpace(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 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 <- fmt.Errorf("panic in ssh exec: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Run rather than CombinedOutput so the assigned writers are used;
|
||||||
|
// Run returns only after both streams are fully drained.
|
||||||
|
done <- session.Run(command)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
text := collected()
|
||||||
|
// 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 err != nil {
|
||||||
|
if text != "" {
|
||||||
|
return text, fmt.Errorf("%w: %s", err, text)
|
||||||
|
}
|
||||||
|
return text, fmt.Errorf("exec: %w", 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 Run, but we
|
||||||
|
// don't wait for it — the caller needs an answer now, not an
|
||||||
|
// indefinite hang.
|
||||||
|
session.Close()
|
||||||
|
client.Close()
|
||||||
|
// Return what arrived before it hung, rather than "". A provisioning
|
||||||
|
// command that stalls halfway is precisely when its output matters.
|
||||||
|
return collected(), fmt.Errorf("timed out after %s waiting for command to finish on %s", sshExecTimeout, host)
|
||||||
|
case <-ctx.Done():
|
||||||
|
session.Close()
|
||||||
|
client.Close()
|
||||||
|
return collected(), 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"
|
||||||
|
}
|
||||||
|
// The correlation id was hardcoded to "", so execution events could not be
|
||||||
|
// tied back to the session that caused them — the one join you want when
|
||||||
|
// asking "what did this agent turn actually do?". It is already on the
|
||||||
|
// execution row; read it rather than threading it through eleven callers.
|
||||||
|
var correlationID string
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT correlation_id FROM executions WHERE entity_id = $1`, execID).Scan(&correlationID); err != nil {
|
||||||
|
correlationID = ""
|
||||||
|
}
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", correlationID, 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()
|
||||||
|
// Persist started_at now, not at the end. It was captured here but only
|
||||||
|
// written in the terminal UPDATE, so a running execution reported
|
||||||
|
// started_at = NULL for its entire life — the UI could not show how long
|
||||||
|
// anything had been going, which is exactly when you want to know.
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`UPDATE executions SET status = 'running', started_at = $2 WHERE entity_id = $1`,
|
||||||
|
execID, startedAt); err != nil {
|
||||||
|
slog.Error("httpapi: mark execution running", "error", err, "execution_id", execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream output for the actions whose output an operator actually watches:
|
||||||
|
// a long apt upgrade, a pct create, an arbitrary approved `run`. The small
|
||||||
|
// internal lookups further down (listing template cache, pvesh nextid) stay
|
||||||
|
// unstreamed — they are plumbing, and logging them would bury the command
|
||||||
|
// the operator approved.
|
||||||
|
var correlationID string
|
||||||
|
if qerr := pool.QueryRow(ctx,
|
||||||
|
`SELECT correlation_id FROM executions WHERE entity_id = $1`, execID).Scan(&correlationID); qerr != nil {
|
||||||
|
correlationID = ""
|
||||||
|
}
|
||||||
|
sink, flushLogs := execlog.New(ctx, pool, execID, correlationID)
|
||||||
|
defer flushLogs()
|
||||||
|
|
||||||
|
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 = sshExecStream(ctx, host, user, cmd, sink)
|
||||||
|
|
||||||
|
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 = sshExecStream(ctx, host, user, cmd, sink)
|
||||||
|
|
||||||
|
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 = sshExecStream(ctx, host, user, createCmd, sink)
|
||||||
|
|
||||||
|
// 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 = sshExecStream(ctx, host, user, cmd, sink)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
162
internal/httpapi/approval_rules.go
Normal file
162
internal/httpapi/approval_rules.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
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", "",
|
||||||
|
nil,
|
||||||
|
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, "",
|
||||||
|
nil,
|
||||||
|
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
|
||||||
|
}
|
||||||
287
internal/httpapi/approvals.go
Normal file
287
internal/httpapi/approvals.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
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", "",
|
||||||
|
nil,
|
||||||
|
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
|
||||||
|
}
|
||||||
20
internal/httpapi/audit.go
Normal file
20
internal/httpapi/audit.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serveAuditDrift returns a read-only DB-side drift report: orphan check
|
||||||
|
// entities, checks on retired targets, probes stuck down/unknown, unmonitored
|
||||||
|
// declared types, and dangling edges. Companion to the knowledge-graph-audit
|
||||||
|
// skill. Live-infra discovery (pct/docker/certs) is a follow-up.
|
||||||
|
func (s *Server) serveAuditDrift(w http.ResponseWriter, req *http.Request) {
|
||||||
|
findings, summary := audit.Report(req.Context(), s.pool)
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"findings": findings,
|
||||||
|
"summary": summary,
|
||||||
|
"note": "read-only DB drift report; live-infra discovery (pct/docker/certs) is a follow-up",
|
||||||
|
})
|
||||||
|
}
|
||||||
103
internal/httpapi/autonomy.go
Normal file
103
internal/httpapi/autonomy.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
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", "",
|
||||||
|
nil,
|
||||||
|
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
|
||||||
|
}
|
||||||
324
internal/httpapi/checks.go
Normal file
324
internal/httpapi/checks.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
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, cd.last_health, cd.last_run_at
|
||||||
|
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
|
||||||
|
// last_health is what turns a check list from configuration into an
|
||||||
|
// explanation: an entity's health is the worst of these, so this is
|
||||||
|
// the field that says which probe is responsible.
|
||||||
|
var lastHealth *string
|
||||||
|
if err := rows.Scan(&c.Id, &c.Slug, &c.Kind, &targetSlug, &c.TargetType,
|
||||||
|
&configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version,
|
||||||
|
&lastHealth, &c.LastRunAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastHealth != nil {
|
||||||
|
h := gen.CheckLastHealth(*lastHealth)
|
||||||
|
c.LastHealth = &h
|
||||||
|
}
|
||||||
|
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", "",
|
||||||
|
nil,
|
||||||
|
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, "",
|
||||||
|
nil,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// Carried through so toggling a check does not blank its verdict in the
|
||||||
|
// UI — the entity window renders last_health to explain which probe is
|
||||||
|
// responsible for an entity's health, and a patch response missing it
|
||||||
|
// would erase that until the next poll.
|
||||||
|
c.LastRunAt = cd.LastRunAt
|
||||||
|
if cd.LastHealth != nil {
|
||||||
|
h := gen.CheckLastHealth(*cd.LastHealth)
|
||||||
|
c.LastHealth = &h
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -3,11 +3,22 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
"github.com/dtoro/oikos/internal/db"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ensureDefaultChecks(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType string, attrsJSON []byte) {
|
// ensureDefaultChecks derives an entity's default checks from the monitoring
|
||||||
checkdefaults.Ensure(ctx, tx, entityID, slug, entityType, attrsJSON)
|
// kinds its type declares. Thin wrapper over the shared db.EnsureEntityChecks
|
||||||
|
// hook so the HTTP create/patch paths and the MCP entity-mutation tools stay
|
||||||
|
// in lockstep.
|
||||||
|
//
|
||||||
|
// Note the ordering caveat (carried from db.LoadTypeTree / checkdefaults.Ensure):
|
||||||
|
// an entity created through the API usually has no edges yet, so a type whose
|
||||||
|
// address comes from its host (a service) will produce no checks on this pass.
|
||||||
|
// That gap is real and deliberately visible — coverageSweep reports it, and
|
||||||
|
// the next inventory ingest fills it in once the hosting edge exists.
|
||||||
|
func ensureDefaultChecks(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType, name string, attrsJSON []byte) error {
|
||||||
|
_, err := db.EnsureEntityChecks(ctx, tx, entityID, slug, entityType, name, attrsJSON)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
162
internal/httpapi/entity_types.go
Normal file
162
internal/httpapi/entity_types.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
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", "",
|
||||||
|
nil,
|
||||||
|
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, "",
|
||||||
|
nil,
|
||||||
|
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
|
||||||
|
}
|
||||||
55
internal/httpapi/execution_logs.go
Normal file
55
internal/httpapi/execution_logs.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/execlog"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serveExecutionLogs returns an execution's streamed command output.
|
||||||
|
//
|
||||||
|
// Registered as a carve-out rather than through the OpenAPI codegen for the
|
||||||
|
// same reason as /activity/recent: it is a recency-ordered projection with no
|
||||||
|
// schema type yet. Without this the execution_logs rows would be write-only —
|
||||||
|
// which is the exact shape of the bugs this whole change set has been about.
|
||||||
|
func (s *Server) serveExecutionLogs(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ctx := req.Context()
|
||||||
|
|
||||||
|
rawID := chi.URLParam(req, "id")
|
||||||
|
execID, err := uuid.Parse(rawID)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid execution id", rawID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 1000
|
||||||
|
if l := req.URL.Query().Get("limit"); l != "" {
|
||||||
|
if n, perr := strconv.Atoi(l); perr == nil && n > 0 && n <= 5000 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks, err := execlog.Read(ctx, s.pool, execID, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also hand back the concatenation, since that is what a caller tailing
|
||||||
|
// output actually wants to render.
|
||||||
|
var combined strings.Builder
|
||||||
|
for _, c := range chunks {
|
||||||
|
combined.WriteString(c.Chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"items": chunks,
|
||||||
|
"combined": combined.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
321
internal/httpapi/executions.go
Normal file
321
internal/httpapi/executions.go
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
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"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Executions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ListExecutions returns executions newest-first.
|
||||||
|
//
|
||||||
|
// The target/action/correlation_id filters are declared in the OpenAPI spec and
|
||||||
|
// generated into the request struct, but were never bound — so
|
||||||
|
// `GET /executions?target=<id>` silently returned the first page of the whole
|
||||||
|
// fleet. Ordering was by target slug, which is neither useful for a history
|
||||||
|
// view nor unique enough to paginate on: several executions share a target, so
|
||||||
|
// a slug cursor could skip or repeat rows.
|
||||||
|
func (s *Server) ListExecutions(ctx context.Context, req gen.ListExecutionsRequestObject) (gen.ListExecutionsResponseObject, error) {
|
||||||
|
limit := clampLimit(req.Params.Limit)
|
||||||
|
|
||||||
|
cursorTime, cursorID, err := parseExecutionCursor(req.Params.Cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
-- target accepts a slug or a uuid: the SPA passes an entity id,
|
||||||
|
-- while a human poking the API reaches for the slug.
|
||||||
|
AND ($2::text IS NULL OR te.slug = $2 OR e.target_entity_id::text = $2)
|
||||||
|
-- the run tool encodes action as "run:{json}", so match the verb too
|
||||||
|
AND ($3::text IS NULL OR e.action = $3 OR split_part(e.action, ':', 1) = $3)
|
||||||
|
AND ($4::text IS NULL OR e.correlation_id = $4)
|
||||||
|
AND ($5::timestamptz IS NULL
|
||||||
|
OR (e.created_at, e.entity_id) < ($5::timestamptz, $6::uuid))
|
||||||
|
ORDER BY e.created_at DESC, e.entity_id DESC
|
||||||
|
LIMIT $7`,
|
||||||
|
req.Params.Status, req.Params.Target, req.Params.Action, req.Params.CorrelationId,
|
||||||
|
cursorTime, cursorID, 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]
|
||||||
|
last := items[len(items)-1]
|
||||||
|
cursor := formatExecutionCursor(last.CreatedAt, last.Id)
|
||||||
|
next = &cursor
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []gen.Execution{}
|
||||||
|
}
|
||||||
|
return gen.ListExecutions200JSONResponse{Items: items, NextCursor: next}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executions are ordered by (created_at DESC, entity_id DESC), so the cursor
|
||||||
|
// has to carry both — created_at alone is not unique, and paginating on a
|
||||||
|
// non-unique key drops or repeats rows at page boundaries.
|
||||||
|
func formatExecutionCursor(createdAt time.Time, id uuid.UUID) string {
|
||||||
|
return createdAt.UTC().Format(time.RFC3339Nano) + "," + id.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExecutionCursor(cursor *string) (*time.Time, *uuid.UUID, error) {
|
||||||
|
if cursor == nil || *cursor == "" {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
rawTime, rawID, ok := strings.Cut(*cursor, ",")
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, rawTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(rawID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return &t, &id, 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", "",
|
||||||
|
nil,
|
||||||
|
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", "",
|
||||||
|
nil,
|
||||||
|
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
|
||||||
|
}
|
||||||
64
internal/httpapi/executions_cursor_test.go
Normal file
64
internal/httpapi/executions_cursor_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The cursor carries both created_at and entity_id because executions are
|
||||||
|
// ordered by the pair. created_at alone is not unique — several executions can
|
||||||
|
// share a millisecond — and paginating on a non-unique key silently drops or
|
||||||
|
// repeats rows at page boundaries. The previous cursor was the target slug,
|
||||||
|
// which is far less unique still: every execution against the same host shares
|
||||||
|
// it.
|
||||||
|
func TestExecutionCursorRoundTrips(t *testing.T) {
|
||||||
|
created := time.Date(2026, 7, 28, 9, 15, 30, 123456789, time.UTC)
|
||||||
|
id := uuid.MustParse("018f3a2b-0000-7000-8000-000000000042")
|
||||||
|
|
||||||
|
cursor := formatExecutionCursor(created, id)
|
||||||
|
|
||||||
|
gotTime, gotID, err := parseExecutionCursor(&cursor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if !gotTime.Equal(created) {
|
||||||
|
t.Errorf("time round-trip: got %v, want %v", gotTime, created)
|
||||||
|
}
|
||||||
|
if *gotID != id {
|
||||||
|
t.Errorf("id round-trip: got %v, want %v", *gotID, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutionCursorNanosecondsSurvive(t *testing.T) {
|
||||||
|
// Truncating to seconds would make the cursor ambiguous for executions
|
||||||
|
// started in the same second, which is the normal case for a plan whose
|
||||||
|
// steps run back to back.
|
||||||
|
a := time.Date(2026, 7, 28, 9, 15, 30, 1, time.UTC)
|
||||||
|
b := time.Date(2026, 7, 28, 9, 15, 30, 2, time.UTC)
|
||||||
|
id := uuid.New()
|
||||||
|
|
||||||
|
if formatExecutionCursor(a, id) == formatExecutionCursor(b, id) {
|
||||||
|
t.Error("cursors one nanosecond apart must not collide")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutionCursorRejectsGarbage(t *testing.T) {
|
||||||
|
empty := ""
|
||||||
|
tm, id, err := parseExecutionCursor(&empty)
|
||||||
|
if err != nil || tm != nil || id != nil {
|
||||||
|
t.Errorf("empty cursor should mean 'no cursor', got %v/%v/%v", tm, id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tm, id, err := parseExecutionCursor(nil); err != nil || tm != nil || id != nil {
|
||||||
|
t.Errorf("nil cursor should mean 'no cursor', got %v/%v/%v", tm, id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"nonsense", "2026-07-28T09:15:30Z", "notatime,018f3a2b-0000-7000-8000-000000000042", "2026-07-28T09:15:30Z,notauuid"} {
|
||||||
|
b := bad
|
||||||
|
if _, _, err := parseExecutionCursor(&b); err == nil {
|
||||||
|
t.Errorf("cursor %q should have been rejected", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,14 @@ const (
|
|||||||
CheckKindTcp CheckKind = "tcp"
|
CheckKindTcp CheckKind = "tcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Defines values for CheckLastHealth.
|
||||||
|
const (
|
||||||
|
CheckLastHealthDegraded CheckLastHealth = "degraded"
|
||||||
|
CheckLastHealthDown CheckLastHealth = "down"
|
||||||
|
CheckLastHealthHealthy CheckLastHealth = "healthy"
|
||||||
|
CheckLastHealthUnknown CheckLastHealth = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
// Defines values for CheckCreateKind.
|
// Defines values for CheckCreateKind.
|
||||||
const (
|
const (
|
||||||
CheckCreateKindCertExpiry CheckCreateKind = "cert-expiry"
|
CheckCreateKindCertExpiry CheckCreateKind = "cert-expiry"
|
||||||
@@ -273,10 +281,10 @@ const (
|
|||||||
|
|
||||||
// Defines values for TrendDirection.
|
// Defines values for TrendDirection.
|
||||||
const (
|
const (
|
||||||
Degrading TrendDirection = "degrading"
|
TrendDirectionDegrading TrendDirection = "degrading"
|
||||||
Improving TrendDirection = "improving"
|
TrendDirectionImproving TrendDirection = "improving"
|
||||||
Stable TrendDirection = "stable"
|
TrendDirectionStable TrendDirection = "stable"
|
||||||
Unknown TrendDirection = "unknown"
|
TrendDirectionUnknown TrendDirection = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines values for ListApprovalsParamsStatus.
|
// Defines values for ListApprovalsParamsStatus.
|
||||||
@@ -462,7 +470,13 @@ type Check struct {
|
|||||||
Id openapi_types.UUID `json:"id"`
|
Id openapi_types.UUID `json:"id"`
|
||||||
IntervalS int `json:"interval_s"`
|
IntervalS int `json:"interval_s"`
|
||||||
Kind CheckKind `json:"kind"`
|
Kind CheckKind `json:"kind"`
|
||||||
Slug string `json:"slug"`
|
|
||||||
|
// LastHealth This check's own most recent verdict. An entity's health is the worst of these across its enabled checks, so this is what explains *why* an entity is degraded. Null until the check first runs.
|
||||||
|
LastHealth *CheckLastHealth `json:"last_health"`
|
||||||
|
|
||||||
|
// LastRunAt When this check last executed. Null = never run.
|
||||||
|
LastRunAt *time.Time `json:"last_run_at"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
|
||||||
// Target Entity slug (instance-scoped)
|
// Target Entity slug (instance-scoped)
|
||||||
Target *string `json:"target"`
|
Target *string `json:"target"`
|
||||||
@@ -477,6 +491,9 @@ type Check struct {
|
|||||||
// CheckKind defines model for Check.Kind.
|
// CheckKind defines model for Check.Kind.
|
||||||
type CheckKind string
|
type CheckKind string
|
||||||
|
|
||||||
|
// CheckLastHealth This check's own most recent verdict. An entity's health is the worst of these across its enabled checks, so this is what explains *why* an entity is degraded. Null until the check first runs.
|
||||||
|
type CheckLastHealth string
|
||||||
|
|
||||||
// CheckCreate defines model for CheckCreate.
|
// CheckCreate defines model for CheckCreate.
|
||||||
type CheckCreate struct {
|
type CheckCreate struct {
|
||||||
Config *map[string]interface{} `json:"config,omitempty"`
|
Config *map[string]interface{} `json:"config,omitempty"`
|
||||||
@@ -8208,175 +8225,177 @@ func (sh *strictHandler) GetTrends(w http.ResponseWriter, r *http.Request, entit
|
|||||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||||
var swaggerSpec = []string{
|
var swaggerSpec = []string{
|
||||||
|
|
||||||
"H4sIAAAAAAAC/+x963IbudXgq6C4WzXUpCn6MpPsyLU/FFljO7FjraXJt6mRiwK7D0mM0EAPgKbEuFyV",
|
"H4sIAAAAAAAC/+x963IbudXgq6C4WzXUpClq7JlkI9f3Q5E1thM71lqafJuKXBTYfUhihAZ6ADQlxuWq",
|
||||||
"X/sAW3nCPMlXuHY3iSabF1nOVP7YkhqNBs4FOPfzqZfyvOAMmJK9k0+9GeAMhPnx/ApP9f8ZyFSQQhHO",
|
"/NoH2MoT5km2cO1uEk02L7KcqfyxJTUaDZwLcO7nUy/lecEZMCV7p596M8AZCPPjxTWe6v8zkKkghSKc",
|
||||||
"eie9DyB5KVJAcxCScIYmXKA3k8E7rNJZL+nJdAY51u+pRQG9k55UgrBp7/Pnz0mvwALnoNwHzkohuVj9",
|
"9U57H0DyUqSA5iAk4QxNuEBvJoN3WKWzXtKT6QxyrN9TiwJ6pz2pBGHT3ufPn5NegQXOQbkPnJdCcrH6",
|
||||||
"xPsC/1oCSs1jNBE8RxgVAuaElxIJkAVnEr6RiMG9GtlhvaRH9Lu/liAWvaTHcK4/Hh62LyvpnTNF1OJN",
|
"ifcF/qUElJrHaCJ4jjAqBMwJLyUSIAvOJHwjEYMHNbLDekmP6Hd/KUEsekmP4Vx/PDxsX1bSu2CKqMWb",
|
||||||
"trqSn3568xJxgSQtp6gPx9NjdDPjUp3MyrEg8ubIf7bAalZ9lWS9pCfg15IIyHonSpTQZQWXtIwA3D5r",
|
"bHUlP/305iXiAklaTlEfjqfH6HbGpTqdlWNB5O2R/2yB1az6Ksl6SU/ALyURkPVOlSihywquaBkBuH3W",
|
||||||
"LOFOnuQ4HeSEkZsE3dD79CTFWbZoW49+d8sV/Sh4fkX025+igNVYaYB1wkWOVe+kl2EFA6VfTSLzvskg",
|
"WMK9PM1xOsgJI7cJuqUP6WmKs2zRth797pYr+lHw/Jrotz9FAaux0gDrhIscq95pL8MKBkq/mkTmfZNB",
|
||||||
"L7gCli7+DIvV3Z5RAkwNpsBAYAUZuoXFCySgoHgh0R1RM8LQs+9mSIAqBUNqBogLMiUM00AaHgqWmKtF",
|
"XnAFLF38CRaruz2nBJgaTIGBwAoydAeLF0hAQfFConuiZoShZ9/PkABVCobUDBAXZEoYpoE0PBQsMVeL",
|
||||||
"1z4+0F+vrz/H92+BTdWsd/L02f+KLn1iaXwVQ1d4WpEp4QK9Or96gb57+gxxFvgkJzJ3PBJfXMVD2yDq",
|
"rn18oL9eX3+OH94Cm6pZ7/S7Z/8ruvSJpfFVDF3jaUWmhAv06uL6Bfr+u2eIs8AnOZG545H44ioe2gZR",
|
||||||
"LcmJasMSNQ/rE2QwwSVVvZPvnyR6zyQv897Jsyf6N8Lsb0/D7glTMAVhPnTF19GD4ttTw2e9U4sxcx5c",
|
"b0lOVBuWqHlYnyCDCS6p6p3+cJLoPZO8zHunz070b4TZ374LuydMwRSE+dA1X0cPim9PDZ/1Ti3GzHlw",
|
||||||
"AMsIm54WheBzTPWfUs4UMLM/XBSUpFjDfPiL1ID/VPvg/xQw6Z30/sewOs6G9qkchgnNJ5fobYbZFJBU",
|
"CSwjbHpWFILPMdV/SjlTwMz+cFFQkmIN8+HPUgP+U+2D/1PApHfa+x/D6jgb2qdyGCY0n1yitxlmU0BS",
|
||||||
"eArZC4RRDgoPsHsD3WGJUgGGEvtZielAr0hwetT7nPQuBB9TyNcstLAjfrfdgv28kfWeC8EF6n/48Qz9",
|
"4SlkLxBGOSg8wO4NdI8lSgUYSuxnJaYDvSLB6VHvc9K7FHxMIV+z0MKO+M12C/bzRtZ7IQQXqP/hx3P0",
|
||||||
"8N33fzDLuCRThulPhYZ1djCo2Vlja3BfQtKP8Jg3WDydAlOnqSJzogyDF4IXIBSxSMbuychSw6ceME1z",
|
"++9/+J1ZxhWZMkx/KjSss4NBzc4aW4P7EpJ+hMe8weLZFJg6SxWZE2UYvBC8AKGIRTJ2T0aWGj71gGma",
|
||||||
"P/cU53SUYkoNA2DJmaYS/e2UaAbqJb08LUae7kCmmJp99T6ukFbSw3oVI5JFmCbppVwIsC+7IaykFI8p",
|
"+1tPcU5HKabUMACWnGkq0d9OiWagXtLL02Lk6Q5kiqnZV+/jCmklPaxXMSJZhGmSXsqFAPuyG8JKSvGY",
|
||||||
"eI5beSUrhR2fyzXjA78kPTDHdtfpGwutzUJYUaqRLPMci0WnmXiptn1FgpRbgEKWaQpyHRjGnFPATA9W",
|
"gue4lVeyUtjxuVwzPvBL0gNzbHedvrHQ2iyEFaUayTLPsVh0momXattXJEi5BShkmaYg14FhzDkFzPRg",
|
||||||
"/BbYKOWlJcfNcDNkYA+VDmuxQkvHu6c6Vn+2V7SSvRqlJEu0WZEVH/8CqdLfq59Nq3Rt2WuV3OwBMsKq",
|
"xe+AjVJeWnLcDDdDBvZQ6bAWK7R0vHuqY/Vv9opWslejlGSJNiuy4uOfIVX6e/WzaZWuLXutkps9QEZY",
|
||||||
"62It1Wfr39lMs26OcTcygPuCCJBbLdOSTBhblhauy8NuCcvqvA73kJbKMnXBKUkXg9QcxPp3rBQINjDI",
|
"dV2spfps/TubadbNMe5GBvBQEAFyq2Vakgljy9LCdXnYHWFZndfhAdJSWaYuOCXpYpCag1j/jpUCwQYG",
|
||||||
"aGfwAi8oxxGZzYhIGjdcQobs7Cgjk0k7yCr8CiJvRynFlrxXSd9JaKsPFFalrG+xsJeZpipDM5CZs4wR",
|
"Ge0MXuAF5TgisxkRSeOGS8iQnR1lZDJpB1mFX0Hk3Sil2JL3Kuk7CW31gcKqlPUtFvYy01RlaAYyc5Yx",
|
||||||
"84OFtRUT5/wWsugeZWkXtlYm1BJQuK9SzlIQTG4mjxg/ODnRkXIDGg6HYacNcmmQ+Dq2+VBS2Ip1cKk4",
|
"Yn6wsLZi4pzfQRbdoyztwtbKhFoCCvdVylkKgsnN5BHjBycnOlJuQMPhMOy0QS4NEl/HNh9KCluxDi4V",
|
||||||
"4/liRGEOtA5f/aS6Bgw/wBxEFI7uLPY3ThOW7/ACjQHhsVQCpwr1CZuBIEqijN+xoy6M1pELNhFXygsY",
|
"ZzxfjCjMgdbhq59U14DhB5iDiMLRncX+xmnC8h1eoDEgPJZK4FShPmEzEERJlPF7dtSF0TpywSbiSnkB",
|
||||||
"2bWuR7lWuQoQAzsW8TkIQTKQXdbqxNHYdRMjiTgtLKGlmnUT8s8MnTw+CeyLm/XM1AloUVCVGVHnTInF",
|
"I7vW9SjXKlcBYmDHIj4HIUgGsstanTgau25iJBGnhSW0VLNuQv65oZOnJ4F9cbOemToBLQqqMiPqgimx",
|
||||||
"diBKFRddr287eFn6MrdgL+npL2JlVeaFVOCVvKykLZDdRZgChQmtbaWCwGHEphzUjHebwmjKncQeY/YY",
|
"2A5EqeKi6/VtBy9LX+YW7CU9/UWsrMq8kAq8kpeVtAWyuwhToDChta1UEDiM2JSDmvFuUxhNuZPYY8we",
|
||||||
"kaLbaHNMjlKeQUe55wCSTIXZwLhxKrOEeAlK6RlXSO3WauZwj/NCr7k3pXyM6bGm4BFOVexwK61SsJX0",
|
"I1J0G22OyVHKM+go9xxAkqkwGxg3TmWWEK9AKT3jCqndWc0cHnBe6DX3ppSPMT3WFDzCqYodbqVVCraS",
|
||||||
"MMe0jLNj91Pq1ujxdqb159DZDNLb1c2mnE1IxO7yV0yJ1XP0Uetuvwi9arTWybAm/Ha8F/TWxBzTkYxT",
|
"HuaYlnF27H5K3Rk93s60/hw6n0F6t7rZlLMJidhd/oIpsXqOPmrd7RehV43WOhnWhN+O94LemphjOpJx",
|
||||||
"87L0NFOq0POk+t+MyFt9AYNQA3Mla3Bkgkw0lgorgUg5G9itxcWMNqlGYTGFDeJHnzCpMEthYM7IrNOF",
|
"al6WnmZKFXqeVP+bEXmnL2AQamCuZA2OTJCJxlJhJRApZwO7tSgHUyzVaAaYqohx43pGJEo16L6RiN8z",
|
||||||
"aSduuZDd7Poh6ut/t5qZ5MC1/hMH5Rq6Snp/56yL1rFGcnJUUkNofUUVtXQg1LabsiLXdbQYzDxtelmT",
|
"lHOpkIAUmEJzEBlJ1TE6Y8hy7jcS2ZkQkUY0uedCKsQn+hcJCKeCS4n09epAZyeXCZIcKf0xItH9DCsE",
|
||||||
"5sLw3z95knydFLiJhtZTQtjg0+j+PObXY7qO5Fa8XXgb4S5o24SnyLWxjt4/xxappREycTah3SSx1J+k",
|
"DwXFhEn07f1s8S3C/hN6QAZTgTPIjtGfS0pRyRSh5nNmMjQh+qOiZPJYXxAebmZhBj7udf0jv9cndcnu",
|
||||||
"K0PGFEs1EjgjVhsiCvK4ROX+gIXAi7gYcRA9uuMR7JTOkUFTBixddxCwMh9b6Fd2qhhiBaQ8z4EZPT6A",
|
"mP7pY4cr1MBLlMzRehNe/z0DZvdhl6IHIyvvhuX+FzIXlV6gXt9u0n2rLKqwmMIGobFPmFSYpTAwN1vW",
|
||||||
"dV8dVPBSwbIYPLC3ck0UnnHaolQaq11nY88toZ0HV9y6wxEaF5rtbpsWwSVS2ah9Wp/CGWcK7lWE4o0B",
|
"ScyxE7eIUW52/RD19b9bzUxy4FprjTPAmtMg6f2dsy664hp51/F2jQ3rK6p4vMPx0ibfVIfMuhMkGOfa",
|
||||||
"aEIoyJG1QkSsChdYzSTiE2RGI33pidKsGJk3kZphhfzryRaEL4mjtuYHr0gOUuG8MNqeVvIZ3CtUcEqR",
|
"tOnmSRGG//bkJHmCc6MDBW6iofWUEDb4XXR/HvPrMV1HciveLr1ldxe0bcJT5LJfR++fY4vUMiSZOEve",
|
||||||
"hh1IjfBuTCB5IS1pT9t3eCVKQGSCjvXo4wXO9XdSUmjYydrOYjY+TruAzowbfnssQZXFsZztDrPaNb6k",
|
"bvJz6u+/lSFje6LhjFgdlijI43Kw+wMWAi/iwt9BrB8dL05nKhgZNGXA0nUHASvzsYV+ZV2MIVZAyvMc",
|
||||||
"zHPGFWckRalFd3C/OKZNNsmTay9mQ0iXkAqw4vqK2CxXl/SGTYgkKaZImheRHoawsaGSMQWkOFIzIlFq",
|
"mLG+BLDuazkQvFSwrLwMrCxVU2BmnLaYAoyttbOJ7o7QzoMrbt3hCI2rOna3TTvuEqlstBlYT9A5Zwoe",
|
||||||
"Zt8CDquSsIwu+yWWszHHIrusjMJLLOBUdDnyZqvoZWOUMwJyNF6MtIJjyBZnGdFbxfSiMefq60t2Oivm",
|
"VITijdluQijIkbUdRWxBl1jNpBY+zGikLz1RmhUj8yZSWtDwrydbEL4kjtqWpCWSg1Q4L4yOrgUSBg8K",
|
||||||
"GdOw1ECBDI0XyM6btGiG7uP+0j/wt53qtPrpuT4hhNvw0lT6mZ9pXKa3oOxk3w9ywkoFyF/hCcq5VJqp",
|
"FZxSpGEHUrVd+BGFo5CWtKftO7wWJSAyQcd69PEC5/o7KSk07GRtZzHLLKddQGfGDb89lqDK4ljOdodZ",
|
||||||
"9Bv6oqzjuokQO1H3ay4Y2DdQt5vXvxCjlmVeC7baA6HdTxfHfGIAg559N4shYgaYqoh0lcFU4AxarAEZ",
|
"7RpfMsFwxhVnJEWpRXdwmjmmTTZpAWsvZkNIV5AKsErWirIjV5f0hk2IJCmmSJoXkR6GsLF8kzEFpJy0",
|
||||||
"v2uR+O18i/hDqTCNINysj4+lxqnZB6cZ6BMaW2+0EY6+kQjuC0g1LaTYChQxwbNkt6xldUuY80tNqs26",
|
"mprZt4DDqv4io8t+ieVszLHIripT/hILOMOKHHljY/SyMVIzATkaL0ZaLTVki7OM6K1ietmYc/X1Jeuq",
|
||||||
"nVWzxNBpr3uLO5iDcAasXdHHC2CozzgbCJCcziE7cn7AVXz6z62samlrK5wdO2kC8uNbSiJnWJx2G+wc",
|
"FfOMQV9qoECGxgtk501a9Hn3cX/pH/jbTuFd/fRcnxDCbXhpKv3MzzQu0ztQdrIfBjlhpQLkr/CkofDo",
|
||||||
"g9g5E5zSD+6OXaG1GZfK+6easDlNVYkp8gPMlTcDBGY+wqYox+mMsCgD5yBnzrbUnPTSRtvo5+jNhREG",
|
"i7KO6yZC7ETdr7ngFtlA3W5e/0KMWpZ5LVjYD4R2P10c84lVc559P4sholItm+AKWlh8AVodiz7xelz0",
|
||||||
"tIBqzq+5NVFYsalVqdoUTnInTxjcDSguFC+ONtqbzLTr4OZiMGJy1qgQZI4VjG5jsR+nr84Hl+dnH86v",
|
"oVSYRhBu1sfHUuPU7IPTDPQJjVmlN34jtdIJqaaFFFuBIiZ4emVxM+Y6qpwrILPXvcWdVhWd2XFX9PEC",
|
||||||
"Bn8+/9vg+PjYbJdyfXlmkIpF0bZXM3c5piSNT42n8FTPZ8doGjVTX76/uKxJOXHjjLu+R/Z+drJw2x3/",
|
"GOozzgYCJKdzyI6c93YVn/5zK6ta2toKZ8dOmoD8+JaSyBkWp90GO8cgdsEEp/SDu2NXaG3GpfJexSZs",
|
||||||
"EyNagsD0tFQzd6WjNy87zWzlg61nd6/FiMrS28gTzMh4Y9d9wL1RkZiVU5B9cRNpLGEhWUF5HJztoIiT",
|
"zlJVYor8AGeqQGDmI2yKcpzOCIsycA5y5iyCzUmvbIyUfo7eXBphQAuo5vyaW8OSFZtalapNQUD38pTB",
|
||||||
"mYoHFiglyLhUjXOsem0X5bG6blqvAMiQHZWguxkwx/AGdESinDOiuHX0BdtJh4PcXz4fD+d8Moq5uZsc",
|
"/YDiQvHiaKOV0Ey7Dm4uciYmZ40KQeZYweguFrFz9upicHVx/uHievCni78Ojo+PzXYp15dnBqlYFG17",
|
||||||
"CJpbMou3SzNBL40ttikaG9eWY32RMMxSGJVMWZv/blP5I7f1mKus1bUIvV6Lab5jdEGbbWknY/d2jjdn",
|
"NXOXY0rS+NR4Ct/p+ewYTaNm6qv3l1c1KSduUnPX98jez04Wbrvjf2JESxCYnpVq5q509OZlp5mtfLD1",
|
||||||
"bHL3odl9NUeDnBvLaeeYVtdbg2/abOB4irXOa+hbf+AbicKLIxdQFPn0Rqy1Y6e5kpfWgCettgSIkgmk",
|
"7O61GFFZeht5ghkZH/q6D7g3KhKzcgqyL24ijSUsJCsoj4OzHRRxMlPxcBClBBmXqnGOVa/tojy2WTJr",
|
||||||
"i5TqhTjj3mhJdVjF45KuWEplPL9IizPB/wuV+bnbXdhEUjsCLuLRg6cKUTDcxoLIMCFAM4lyt8JCgASm",
|
"VwBkzkqZoHtrr4OacTHnjChu3bPb2A795fPxcC5Do5ibuylqbDSLd/bWeyxRY4s7WxZzrC8ShlkKI2NZ",
|
||||||
"jnvJFrh7B8KEtM1XcNgFcV+Ec+OovjJWpArDVjFAfSUwk0ZorfYUF1daEHDlyKAFhqN6lGR9QX+6fP8X",
|
"3T0EwR+5rcdc5WOoxVX2WhwqHWNC2mxLO7kotnOXOmOTuw/N7qs5GuTcWE47x7Q6TBt80+a5wFOsdV5D",
|
||||||
"dOlBtdF+13g5su2Ma+BGHxE58nQYNwdTvABRN/7loLC9QAW2JqlSaKxM+RyEQZ9R9qaMtEbSBEB3NfO1",
|
"3/oD30gUXhy5MLDIpzdirR07zZW8tAY8abUlQJRMIF2kVC/EGfdGS6rDKh6XdMVSKuOvR1qcCV57qMzP",
|
||||||
"IrTAQl/ent02GxcNTEdrnTKrkTUmMAjM/VkISE3Q48e9Dlx3ujrEeCg30RFW8nEtfW08ZUcrwbwPQTnB",
|
"3e7CJpLaEXAZj/k8U4iC4TYWRIYJAZpJlLsVFgIkMHXcS7bA3TsQJhBxvoLDLoj7IpwbR/W1sSJVGLaK",
|
||||||
"3zHBVEYdQCuUdEgS2plk1h+3cTytR0iLP+Yg+NiVNqNH1NxF7C77jbaPrsAKP2BsRd2KUKMd3kt6d1h4",
|
"AeorgZk0Qmu1p7i40oKAa0cGLTAc1WNb6wv649X7P6MrD6qN9rvGy5FtZ1wDN/qIyJGnw7g5mOIFiLrx",
|
||||||
"E70gSsvzcQ+EUWnjBk7ZXaAKISxB8LOGgWOBiYRsi8AJd3/XjAluiVHSCrGLW/nOavHIm0N0nCmj6/i0",
|
"LweF7QUqsDVJlUJjZcrnIAz6jLI3ZaQ1/ikAuquZrxWhBRb68vbsttm4aGA6WuuUWY2HMuFcYO7PQkBq",
|
||||||
"4dPr/BbXYFN7Bpk+kN9u63jrzo4+gfOItHR5SyhF9inqr8pM9ok7LI5iEpMAaU7c/T18D+ihs4NrN+Nm",
|
"QlU/7nXgutPVIcZDuYmOsJKPa+lr4yk7WgnBfgzKCf6OCaYy6gBaoaRDktDOJLP+uI3jaT1CWvwxB8HH",
|
||||||
"wK6T1MW+1BOJm3WRu83AWRt5tDaQ1tn6zPGj+XiyqP1sB08wsdEXennZiJfGEq5vOGr/Lrj+YTTG6a37",
|
"rrQZPaLmLs562W+0fUwMVvgRI2LqVoQa7fBe0rvHwpvoBVFano97IIxKGzdwyu4CVQg8CoKfNQwcC0wk",
|
||||||
"Tf84cu993MflWVtIRLLbOho3hOFu6wwNx1ergbM6xaqTVYDB9nqOirAElm1XZ53Gl1jRmpONczDnJggc",
|
"ZFuEu7j7u2ZMcEuMklaION3Kd1aLIt8cWOVMGV3Hpw2fXue3uAab2jM0+JH8dltHyXd29AmcR6SlqztC",
|
||||||
"Musi6/PCGq2PesnW4Ur1ZL6Nl4Oba20c3SuBi9lfCdytwhCyKTQDINal2nxwCJQzUsRcMJUdqs1sv6Nx",
|
"KbJPUX9VZrJP3GFxFJOYBEhz4u7v4XtED50dXLsZNwN2naQu9qWeSLSzi7duhjvbeLG14c/O1meOH83H",
|
||||||
"KRKZGXGTkQwNrssnT55DsHU5jdTavAhLaZnB/7Y0acxHzkMN0Zg5xrMtgOPsfRGwKFGy1Oc8xZ3Z+lMo",
|
"k0XtZzt4gomNvtDLy0a8NJZwfcNR+3fB9Q+jMU7v3G/6x5F77+M+Ls/aQiKS3dYx1CF4eltnaDi+Wg2c",
|
||||||
"xYVZ1IyoiP96WcbkNpDaYjCG9tcGBq2+VO+6aGxwyYUQ8HkovK1Y93Y8jlvjsAQ0I8BIrs9in6Gll+wC",
|
"1SlWnawCDLbXc1SEJbBsuzrrNL7EitacbJyDOTeh+5BZF1mfF9ZofdRLtg5Xqqdgbrwc3Fxrox9fCVzM",
|
||||||
"vZT5QMMntaudba2xxcGwiwdTtqHqP27DFrfhMuQdACu/XBTuf2b8jmq+eU0i10pXOzVht5CNoly0MS5E",
|
"/kLgfhWGkE2hGQCxLkHqg0OgnJEi5oKp7FBtZvsdjUuReNqIm4xkaHBTnpw8h2DrchqptXkRltIyg/+y",
|
||||||
"YHbbKXCrXahhpChil8hrMp1RMp1p1Jg8Xh9h0omvbOx451hzRRSFNTuu+DDjaZnbsBFRsjHnt8YaNAep",
|
"NGnMR85DDdFIR8azLYDj7H0RsChRstRnqsWd2fpTKMWFWdSMqIj/elnG5Db83WIwhvbXBgatvlTvumhs",
|
||||||
"yLQte2qzvdkuIIbkt17Vf6mP7FWOqptio4aKrN0WuCW2FYicaClip5eDNTGiDXyaCJ6foE+Kn6BPDlTy",
|
"cMmFEPB5KLytWPcOHQkooBkBRnJ9Fvu8Or1kF+ilzAe2C4PsJv03jS0Ohl08mLINVf9xG7a4DZch7wBY",
|
||||||
"BP3McA7ZwLBqgo6Pjz9+/vx5o3ub+LQpc6+sGKtr64jB+x0oQdJLEA7Ckctm0aZ45ebdlihCSsuiTkkC",
|
"+eWicP8T4/dU881rErlWutqpCbuDbBTloo1xIQKzu06BW+1CDSNFEbtEXpPpjJLpTKPGZF/7CJNOfGUj",
|
||||||
"3/WS3tOZ/qclctBIg+suNjyfdmK/LfJBc3zfacqcsE7jtrEwhPyEpRIY+A5ZWCCfd7Dhs8vCpex0b4VL",
|
"/jtnCCiiKKzZccWHGU/L3IaNiJKNOb8z1qA5SEWmbTlvm+3NdgExJL/1qv5LfWSvclTdFBs1VGTttsAt",
|
||||||
"d514dGUGRUMqFlYhcFQQcF4hMraICxsJu51xoygoAdkei90Mq21C878IlZwhyu9AoDEvWZZoia2wQSQw",
|
"sa1A5ERLETu9HKyJEW3g00Tw/BR9UvwUfXKgkqfobwznkA0Mqybo+Pj44+fPnze6t4lPdjP3yoqxuraO",
|
||||||
"t+/ZFOLh970ISptj4reyVuFKsXbINo7UYCDYS9wqKlivPPu1xAIzRVhbZPgWuaizRcHVDCT5u8098Iv3",
|
"GLzfgRIkvQLhIBy5bBZtildu3m2JIqS0LOqUJPB9L+l9N9P/tEQOGmlw3cWG59NO7LdFFm+OHzpNmRPW",
|
||||||
"Kc9LBktzgYQxH9tTwNdBczd3Z4OSarqvh1SDlFYwX1OL14Vx1so2LN9eS3lptfxBIbiI3BQ/EqDZwGT0",
|
"adw2FoaQVbJUuATfIwsL5LNFNnx2WbiUne6tcOmuE4+uzaBoSMXCKgSOCgLOK0TGFnFpI2G3M24UBSUg",
|
||||||
"1cJxkB2O+t89e3bUHuVn3Hzx47lNc16CnZ0hjO9yqvh8nQ20E0s12CSUBK9DD495qU7GVMtjteCBUpDN",
|
"22Oxm2G1S2kOhErOEOX3INCYlyxLtMRW2CASmNv3bOL38IdeBKXNMfFbWatwpVg7ZBtHajAQ7CVuFRWs",
|
||||||
"mrf5zFp3y4VWPeRaG8Y6p/a7Ny8TlHIBMkEC56N8nKCMyNvRdJwgUiRIQV5QE4yYm5i2BGmxnaQgo0GJ",
|
"V579UmKBmSKsLTJ8iwzi2aLgagaS/N3mHvjF+0T1JYOluUDCmI/tifvroLmbu7NBSTXd10OqQUormK+p",
|
||||||
"XEbkxUtaTr0790Lw+5zfm8gwF3RVi1GI2jLiEWavyxyzgQCc6XMFOX9Ix8gv8116n578ApQuJoRt7yqn",
|
"xevCOGvFNpZvr6VswlrWpxBcRG6KHwnQbGDyMGvhOMgOR/3vnz07ao/yM26++PHcpjkvwc7OEMZ3OVV8",
|
||||||
"92mC5nmCuEAZT29BmHoomLB6aHV3Z7kD3gYctwWUVem43ewHIRowanYKhjH05qWJMhA4vUWFXwZhU/3L",
|
"vs4G2omlGmwSSoLXoYfHvFSnY6rlsVrwQCnIZs3bfGatu+VSqx5yrQ1jnVP73ZuXCUq5AJkggfNRPk5Q",
|
||||||
"VICxv224JqLXcW9pCWu3fRk4cWnT+mSJ1Myag8CU2oMHkUlz4cHwubsFoMVZf1YKASxETbSGYEgFxTrJ",
|
"RuTdaDpOECkSpCAvqAlGzE1MW4K02E5SkNGgRC4j8uIVLafenXsp+EPOH0xkmAu6qsUoRG0Z8Qiz12WO",
|
||||||
"0ax7lIOUeNrNeTwhjMjZ/vbnh7BhhwBUUTLnETOKWcCDvNVqZsvlqqDoYA3Ro9aekmuTBRwzenxZ9MRm",
|
"2UAAzvS5gpw/pGPkl/kufUhPfwZKFxPCtneV04c0QfM8QVygjKd3IEwVG0xYPbS6u7PcAW8DjtsCyqok",
|
||||||
"adgmN5yzGx0flecvfli60nKBXTqbeMNpa2NH1kzQJqKay3tkypptox+QbKT4rrSzjBMLnaQyPruzsra2",
|
"6m72gxANGDU7BcMYevPSRBkInN6hwi+DsKn+ZSrA2N82XBPR67i3tIS1274KnLi0aX2yRCqdzUFgSu3B",
|
||||||
"TSjqFuXVGTHrbebt+Nj4XjeLXxwgm2AQj/NJscgIw3TJdc0ZDBQfcBOW7X7JMdPEo/+rnvnfzMONtvOY",
|
"g8ikufBg+NzdAtDirD8vhQAWoiZaQzCkgmKd5GjWPcpBSjzt5jyeEEbkbH/782PYsEMAqiiZ84gZxSzg",
|
||||||
"5YNpoRT2C7FxlqROpUx622Zeb3w/Ho9RX1PzC0kD6lG8EXl75j2h8aSkUfXJCm3MIawqV+F/tIluIm85",
|
"Qd5pNbPlclVQdLCG6FFrT8m1yQKOGT2+LHpiszRskxvO2Y2Oj8rzFz8sXUHAwC6dTbzhtLWxI2smaBNR",
|
||||||
"XUMSK6Za5WxRrjbhsgU/cfisbiSyjBhwXAm2VVI2hv6OLl+vI3W8VIVUIwnAtvLWTygu1imDM06zUcbv",
|
"zeU9MsXottEPSDZSfFfaWcaJhU5SGZ/dWVlb2yYUdYvy6oyY9TbzdnxsfK+bxS8OkE0wiMf5pFhkhGG6",
|
||||||
"2L6xhNvWm6pCQ6wAP3Cm77hWv/W+KbkFuhiluOzI13mp9o6n5GlqhK71Bo8DhOlsFAUr06ELuMHprfcB",
|
"5LrmDAaKD7gJy3a/5Jhp4tH/Vc/8b+bhRtt5zPLBtFAK+4XYOEtSpwI0vW0zrze+H4/HqK+p+YWkAfUo",
|
||||||
"eOOC+Y7etlVTbc5QJQp93OWSr2LsbeLkIYpe+fJWtRggJxutwHuZTZaoJ8rJt4TSvWxqmwNxTB7tqLIc",
|
"3oi8O/ee0HhS0qj6ZIU25hBWFRnxP9pEN5G3nK4hiRVTrXK2KFebcNmCnzh8VjcSWUYMOK5w3iopG0N/",
|
||||||
"dHyjc324bexjpdxTqF4TMGgz9Um2pcG/EDyFrBQx8dNHPWbICMJDGz8y9AEgQx8XVFDM0Ifngx+OVkKx",
|
"R5ev15E6XqpCqpEEYFt56ycUF+uUwRmn2Sjj92zfWMJtq4RVoSFWgB8403dcq99635TcAV2MUlx25Ou8",
|
||||||
"vd9uFCKX2qq5MH0OentkvuIL37yReihSPO7Crbuzj92Q56XWKGLu0xXVbeepLFwPMVdEG5Ih8CeeCNnd",
|
"VHvHU/I0NULXeoPHAcJ0NoqClenQBdzg9M77ALxxwXxHb9uqqTZnqBKFPu5yyVcx9jZx8hClynxRsloM",
|
||||||
"XpoJPHGxCz6IIdhJBUyMTbamzG2IQ/bGUrE+7aSyZ+9mOl1JEQnG02AUrVig9Yy6dMrnchRqnmMWMZq8",
|
"kJONVuC9zCZL1BPl5DtC6V42tc2BOCaPdlRZDjq+0bmq3zb2sVLuKVSvCRi0mfok29LgXwieQlaKmPjp",
|
||||||
"4sFYZsrQuTi5Xry8pKtluMw4RIU6WTF3esZLPcCYmWRc6OqedyIgXlxN6W2o1hRYihfdS6xopb8ZWy3l",
|
"ox4zZAThoY0fGfoAkKGPCyooZujD88Hvj1ZCsb3fbhQil9pq8DB9Dnp7ZL7iC9+8kXooUjzuwq27s4/d",
|
||||||
"rJf4qjkmH5y1XLptd98WgI4Xvvn9k42lD9y6k4DuGJVceafUEgAZzzFdtEjTREAVVLZDBMmqwMk1x0Xt",
|
"kOeV1ihi7tMV1W3nqSxcDzFXRBuSIfAnngjZ3V6aCTxxsQs+iCHYSQVMjE22psxtiEP2xlKxPu2ksmfv",
|
||||||
"rsQ45ihhgAUqBP/FfjpBf8iQjfjb7Ets95tKymOa01v7uQlRqACBMrzY2ikY3HQVtKKBGRLSUksoJiHF",
|
"ZjpdSREJxtNgFK1YoPWMunLK53IUap5jFjGavOLBWGaKB7o4uV68KKirQLnMOESF6mYxd3rGSz3AmJlk",
|
||||||
"VQsALECclrFcxfdOLRrOCdyBOEF6mJaebtH7Ny/P0J/+66oe70rY4PTiDfrXP/6JznCWLa7ZhIs7LLIB",
|
"XOjqnnciIF4ST+ltqNYUWIoX3UusaKW/GVst5ayX+Ko5Jh+ctVy6bXffFoCOF7757cnG0gdu3UlAd4xK",
|
||||||
"LtUMEZNtBUzCgLBBBoWaJYhxmxfmrDdaQhOlmh0dXzNTDPrEmAVJiuw6bTKpLZheZZ72TZEvdGMCpW/0",
|
"rr1TagmAjOeYLlqkaSKgCirbIYJkVeDkmuOidldiHHOUMMACFYL/bD+doN9lyEb8bfYltvtNJeUxzemt",
|
||||||
"u76guCEm82ZF7YaVTGlqI9O6mtcu+cGVJM8UF3w1qO3MFvAe6LsckN6sL7DyntxyiWY8B4rH6P3lMbrS",
|
"/dyEKFSAQBlebO0UDG66ClrRwAwJaaklFJOQ4qoFABYgzspYruJ7pxYN5wTuQZwiPUxLT3fo/ZuX5+iP",
|
||||||
"4uWEUNAb10O+/TZs8pqZXX77LeqbmuA4VQMjFx6doFfceAxAIKnKsURYAKpK2t8RNUMcF2Sgj70psOSa",
|
"/31dj3clbHB2+Qb96x//ROc4yxY3bMLFPRbZAJe2FFsGE2ASBoQNMijULEGM27wwZ73REpoo1ezo+IaZ",
|
||||||
"2bxXifr+82dv3yRoUmqpBP30Rh5ZeBkw4xyQLCA9vmbX7IyzuUYnZzX55PnRyTUboHPrhdJf9wXD0U1b",
|
"Et6nxixIUmTXaZNJbZn7KvO0b4p8oVsTKH2r3/Vl4A0xmTcrajesZAqKG5nWVSp3yQ+ukHymuOCrQW3n",
|
||||||
"efKbY/3KWyKVRKUEdPPJ3NFJvcvC5xu7eNeaocBTwqzDq+8OGmRKzqPvnyQox/fo2ZMnR2ben5jEE0AX",
|
"tuz6QN/lgPRmfYGV9+SOSzTjOVA8Ru+vjpEpWTchFHwNum+/DZu8YWaX336L+qaSO07VwMiFR6foFTce",
|
||||||
"7y+vbPGTQqGbpXr8N6hvK/sXFC/QHWEZv7NvvyvNoYCEaz4hUYqFWKAbd9vdvECvzq9cTwCJbs6v8PQm",
|
"AxBIqnIsERaAqkYE90TNEMcFGehjbwosuWE271Wivv/8+ds3CZqUWipBP72RRxZeBsw4ByQLSI9v2A07",
|
||||||
"QRenV2evkY/fQDe+xP4N6rvi/L4ov/1MqLlTwez58+c/oJ+uzszzcxeUZJ7iLBMgpVnXuBldivrNLhEG",
|
"52yu0clZTT55fnR6wwbownqh9Nd9mXd021ZU/vZYv/KWSCVRKQHdfjJ3dFLvjfH51i7eNdQo8JQw6/Dq",
|
||||||
"UVczQO/OLmw5kAlOAfWlEoBzM8Prq6uLBPHJhKQEU01Aly//fGRziEtmItEVuhnmaXFzzTirCGFMGBYL",
|
"u4MGmUYB6IeTBOX4AT07OTky8/7EJJ4Aunx/dW2LnxQK3S51UbhFfduPoaB4ge4Jy/i9fftdaQ4FJFzL",
|
||||||
"hFmmB/NSGTuq4SVL15plXZDQC0RMBiWnEt0JXFyzip6sfoxMSg3ChtqlVrOyghOmpOVHSlJwrhjHZBc2",
|
"EIlSLMQC3brb7vYFenVx7To5SHR7cY2ntwm6PLs+f418/Aa69Y0RblHftVTwrRTsZ0LNnQpmz58//z36",
|
||||||
"u1sf14I6xpQnw6HTvI+dJ3nossBrfsSeZbfTizc1qeWk9/T4yfETo+YWwHBBeie958dPjp9bJ/DMnHdD",
|
"6frcPL9wQUnmKc4yAVKadY2b0aWo3+ztYRB1PQP07vzSlgOZ4BRQXyoBODczvL6+vkwQn0xISjDVBHT1",
|
||||||
"c0gMcK3IvLs0rRGIcPYm6530/k8JYtGsR9/sQfJzvJlBrST4mtYLLe82aojvMEE9dGPtyzHJudrcMHTw",
|
"8k9HNoe4ZCYSXaHbYZ4WtzeMs4oQxoRhsUCYZXowL02FRMtLlq41y7ogoRemWqIpw4PuBS5uWEVPVj9G",
|
||||||
"6DDW9XboMNL1bOkw0jam+PxxqcnDsydPtmpRsBRE6NWGTvpDE/URfaTeQGb7qmVmCZE7euXK8UtAwJSJ",
|
"JqUGYemKLALLCk6YkpYfKUnBuWIck13a7G59XAvqGFOeDodO8z52nuShywKv+RF7lt3OLt/UpJbT3nfH",
|
||||||
"4/qcVIJZfAsBZrVmELVIVttlAY1hhufE1MgwZnY8lcambcNMx8SbXe8HfuG2lmbvpGfFATPrMJROaeUk",
|
"J8cnRs0tgOGC9E57z49Pjp9bJ/DMnHdDc0gMcK01gLs0rRGIcPYm6532/ncJYtHsItDsHPO3eAuKWiH3",
|
||||||
"fS2chlGdmChoHRUuD1cbvY15vBVn5ZN7V5z/zfFGaIryeGwRCGp/ftAEinCNQj0vVCWAtmGE4SeSfR6G",
|
"NQ0zWt5tVH7fYYJ66Mbal2OSc7W5Yei70mGs68jRYaTrtNNhpG0n8vnjUmuOZycnWzWWWAoi9GpDJ/2h",
|
||||||
"1iMa1k2K34Dg0FJK47hwASJNlnppujMENCRbfmGpkZKlJRMN80eeLfYgo/qmQ1qrZVPLpYvAmVHVjPGW",
|
"ifqIPlJv+7N91TKzhMgdvXLl+CUgYMrEcX1OKsEsvoUAs1oLj1okq+2NgcYww3NiamQYMzueSmPTtmGm",
|
||||||
"yN+W4jOv352eVQ0MrG7Ql4RNKQxKCQnyeVNOpB5IksHmMkVhG3FKbLZY+rwnI+7afeilWyQSkHKRQXaI",
|
"Y+LNrg8Dv3BbS7N32rPigJl1GEqntHKSvhbOwqhOTBS0jgqXh6to38Y83oqz8sm9+wT86ngjtLJ5OrYI",
|
||||||
"m8HiyoToAFvUYemg2wrQriwTvG6WacrMxvuvkcHMkG6yV72I+S7Sl62E/5CCV5vYx9kub64k8P1H6Nvv",
|
"BLU/P2gCRbhGoZ4XqhJA2zDC8BPJPg9DwxgN6ybFb0BwaASmcVy4AJEmS700PTUCGpItv7DU/srSkomG",
|
||||||
"Yqt6KDzi1aYX4cU91FcSvTy/PDs6BHubmbeX95o8azzI68W9MzukE9OuiF0daT/EdezArL6S+sqrtcy+",
|
"+QPPFnuQUX3TIa3Vsqnl0kXgzKhqxnhL5G9L8ZnX787Oq7YTVjfoS8KmFAalhAT5vCknUg8kyWBzmaKw",
|
||||||
"3xZl2wYGj0fUjiIOJKwZEkQZTAhz6S8VQbsKjxsktjbJysZAWWg9oli1EZUuVquTPPL0sJ+Ootcmjh8A",
|
"jTglNhtjfd6TEXftGfXSLRIJSLnIIDvEzWBxZUJ0gC3qsHTQbQVoV5YJXjfLNGVm4/3XyGBmSDfZq156",
|
||||||
"v3YmhB2O+/a0GWA5yLDCCfJmyj8cdcZ57PgyQvq+srkvD9MkIVM1ZkcKcm1CH5R0bFWbLyzJtlKObz25",
|
"fhfpy/YveEzBq03s42yXN1cS+P4j9O13sVWdL57watOL8OIe6iuJXl5cnR8dgr3NzNvLe02etZXy14p7",
|
||||||
"P+XYmYzsSqxp1RHRroTSqIyy4cJbGtvNyhHqGHxpidNXxl+1dWzVEOA3d0k2+1s84m25RE4HOFbdjKA1",
|
"53ZIJ6ZdEbs60n6I69iBWX0l9ZVXa5l9vy7Ktm0nno6oHUUcSFizTQ0ymBDm0l8qgnYVHjdIbG2SlY2B",
|
||||||
"O6s4atlyBshFE/JSDvwTZNQypAQm9GhXe4jzSg1tCWODmmiyi68TKX2d4iS4uyTCDOEpoFtYFJiIxPXT",
|
"stB6QrFqIypdrFYneeS7w346il6bOH4A/NqZEHY47tvTZoDlIMMKJ8ibKX931BnnsePLCOn7yua+PEyT",
|
||||||
"NX9vLzybGI9GLTm2EfXFG+kNx+gMUwrC1kvEVADOFmiG56C/4YtYMHPtMMj06dLIjjCBXtbB0TwVbEXj",
|
"hEzVmB0pyDV3fVTSsVVtvrAk20o5vmHo/pRjZzKyK7GmVUdEuxJKozLKhgtvaWw3K0eoY/ClJU5fGX/V",
|
||||||
"M1+X/yFO82ax6S98oC9VbI61GzYjcmAqNNe2HkDbxIBlAWEHoG/7MYQRgztf3Phf//gnIlKW4GnI00+N",
|
"1rFVQ4Bf3SXZ7G/xhLflEjkd4Fh1M4LW7KziqGXLGSAXTchLOfBPkFHLkBKY0KNd7SHOKzW0JYwNaqLJ",
|
||||||
"dsISKip3hNtC4rbZXYPCP0laTj8P06pJSDQM44PzMN7NSDpzvUBM/4/EutUs2Zqy0rbfhm9vgUybD0PE",
|
"Lr5OpPR1ipPg7pIIM4SngO5gUWAiEtcF2fy9vfBsYjwateTYRtQXb6Q3HKNzTCkIWy8RUwE4W6AZnkO9",
|
||||||
"UzIHhpT3NRovM0PeAWwafBivHWFSAc4Qn6ApUagoKY0R6StQzQYnK/dWbAuIM7pwi5NhcURW67JNpp8/",
|
"1RMz1w6DTJ8ujewIE+hlHRzNU8FWND73dfkf4zRvFpv+wgf6UsXmWJNoMyIHpkJLdOsBtE0MWBYQdgD6",
|
||||||
"f/7DUUtzfdu5ZOu23x8fUkRpQCJ2Kru2IBlQhQ9As69AOTJI6zM7iOIKnNsSZ7KTVHtJy2nv88cIZcuq",
|
"th9DGDG498WN//WPfyIiZQmehjz91GgnLKGicke4LSRuWxQ2KPyTpOX08zCtmoREwzA+OA/j/YykM9cL",
|
||||||
"a8l0bZHxgQkwMdZB84Z1Jme+8K6d9hu5cmK3E6ZvmPLgePcfiuD9slPvlQOpth5y65q8fFliyHwLmGGt",
|
"xPT/SKxbzZKtKStt+2349hbItPkwRDwlc2BIeV+j8TIz5B3ApsGH7V7GpAKcIT5BU6JQUVIaI9JXoJoN",
|
||||||
"GE5UEn4FaqVfzAMibuVbMSu5H4P84vfH03sGA8FLlg2UIIUJqdOCT4gFcm3+keA8NyFBqMBT2MPHWi9o",
|
"TlburdgWEGd04RYnw+KIrNZlW4M/f/7cBLvF7z6bhblls/aPjymiNCARO5VdW5AMqMIHoNlXoBwZpPWZ",
|
||||||
"06qC+ACTTWf4j4QqEKY+Qr1ZoivEJZEeDSzDTMm2w3tXC3vIGNz2xVC1des3fTnetS8usXs5tg9drR7O",
|
"HURxBc5tiTPZSaq9ouW09/ljhLJl1bVkurbI+MAEmBjroHnDOpMzX3jXTvuNXDmx2wnTN0x5dLz7D0Xw",
|
||||||
"TGDO0AXJxr7y656m938rLam9qNoX0o5cTT5K5MFOXaiYJyg7tXJVOxsSz/319lVaEhvF/b+wKdFTUbst",
|
"ftWp98qBVFsPuXVNXr4sMWS+BcywVgwnKgm/ArXSL+YREbfyrZiV3I9BfvH74+k9g4HgJcsGSpDChNRp",
|
||||||
"0dRKy8BWzji/wtO2Kd2woRnjJjyIDZKhFeVgA1U0LUh+8DCojO1q8JnTbOtNQCq10/W3mgddOeVMav3c",
|
"wSfEAqU2SggJznMTEoQKPIU9fKz1gjatKogPMNl0hv9IqAJh6iPUmyW6QlwS6dHAMsyUbDu8d7Wwh4zB",
|
||||||
"xH3aqhRac05xgVOjAvuI76MEeUOWm926G6vqtCaiJaIzNxVlfQz63QWXe0ynCFUIvm7aX6kD8oXpf7VG",
|
"bV8MVVu3ftOX41374hK7l2P70NXq4cwE5gxdkGzsK7/saXr/t9KS2ouqfSHtyNXko0Qe7NSFinmCslMr",
|
||||||
"RftJ58qyJk2E/FpC+ahsEraAMNKvlSqQbv/t/z1L0F/fJSjU+DhCZqAp2rEvP3njfZsUGkjvAc0fbceX",
|
"V7WzIfHCX29fpSWxUdz/C5sSPRW12xJNrbQMbOWMi2s8bZvSDRuaMW7Cg9ggGVpRDjZQRdOC5AcPg8rY",
|
||||||
"w5mrB/R42HkVahkshxjvdMkdwE2ynPbge3TUTx0sINJ1pNY5pmoXkMHkxTUjlMIU08YkNpYbfffkBy3X",
|
"rgafO8223gSkUjtdf6t50JVTzqTWz03cp61KoTXnFBc4NSqwj/g+SpA3ZLnZrbuxqk5rIloiOnNTUdbH",
|
||||||
"mukG1fOjY3Rho/im+iPXzB6IWitdVK8+R31/ygW4HEXPO729Xc+6B/b31JvHfHH7YBuDOI9Pdbc+Foc4",
|
"oN9dcLnHdIpQheDrpv2VOiBfmP5Xa1S0n3SuLGvSRMgvJZRPyiZhCwgj/VqpAun23/6f8wT95V2CQo2P",
|
||||||
"h1FV3kIzSK1TzFIXmYOcWkPT5XpQdbluO8L+qMd9sMM6eZNMQk1DDwngeW5KIZK8zHsn30dSuR5am1gO",
|
"I2QGmqId+/KTN963SaGB9B7R/NF2fDmcuXpAT4edV6GWwXKI8U6X3AHcJMtpD75HR/3UwQIiXUdqnWOq",
|
||||||
"EixsslFLk9jOVZnaCiXZD2xd1maLCJ3JxNbZ9ai1du2pwMUMZcTVSDuEUdvnjPgPkok1BbmDfYIJlV/0",
|
"dgEZTF7cMEIpTDFtTGJjudH3J7/Xcq2ZblA9PzpGlzaKb6o/csPsgai10kX16nPU96dcgMtR9LzT29v1",
|
||||||
"OF8laB+AJjdfyKHQSjeKFkB3DuarEuGiHNEbc8MsoZKLSe4zJgbz5OPhTc/7qNzri7zvTMf1aQ/hZHxp",
|
"rHtkf0+9ecwXtw+2MYjz+FR361NxiHMYVeUtNIPUOsUsdZE5yKk1NF2uB1WX67Yj7A963Ac7rJM3ySTU",
|
||||||
"gI5EfVrT9zx40voauiggRx49IvFa23YQqYdVLncbFS8XWHvA63P5UxHsXTT9kFC4Y8jt4wDyPac0XsTO",
|
"NPSQAJ7nphQiycu8d/pDJJXrsbWJ5SDBwiYbtTSJ7VyVqa1Qkv3A1mVttojQmUxsnV2PWmvXngpczFBG",
|
||||||
"mDqXhf4vhcqaadp045Xr44fP5649a4cT58HzrqJW0VrZkP8EAD+ybXPufCaPZdqc20zeAwb9viZScWGc",
|
"XI20Qxi1fc6I/yCZWFOQO9gnmFD5RY/zVYL2AWhy84UcCq10o2gBdOdgvioRLsoRvTE3zBIquZjkPmNi",
|
||||||
"3eBZYWdHxNxCy+SetroDL21qwCUwheyGjtE5Tmf2+99IdEOyG58VbXvgC36HSIb6AmSZwzUzB9nNWy0q",
|
"ME8+Ht70vI/Kvb7I+850XJ/2EE7GlwboSNSnNX3Pgyetr6GLAnLk0RMSr7VtB5F6WOVyt1HxcoG1R7w+",
|
||||||
"mxkGb17eHCXoxoxeelcDNUE3GVY4PPnT5fu/XDPzKrLQPkavAQs1Bqz0uZUbOGvOW6Cn38tj9EeQagCT",
|
"lz8Vwd5l0w8JhTuG3D4OIN9zSuNF7Iypc1no/1KorJmmTTdeuT5++GLu2rN2OHEePe8qahWtlQ35TwDw",
|
||||||
"CRfGDUvMk3/945/XzNSVhgwVIAayHOudjkGgcTmZgEhQJngx4DQDqVwS9cXvj16YNOhX51fIweyaKY7G",
|
"E9s2585n8lSmzbnN5D1g0O9rIhUXxtkNnhV2dkTMLbRM7mmrO/DKpgZcAVPIbugYXeB0Zr//jUS3JLv1",
|
||||||
"OL2dkLgr/tLAtO2wanXhBAigQsCE3O/rsbFKVvViAwVrZ9jMtgrulQXHoKKg9glX/bCX58i9eAiz/9wT",
|
"WdG2B77g94hkqC9AljncMHOQ3b7VorKZYfDm5e1Rgm7N6KV3NVATdJthhcOTP169//MNM68iC+1j9Bqw",
|
||||||
"kJ0T9S8vz4/2YY4qOmqtn64atmsu5INHyH8lGSn/XldHyBJ9xOujoq1DOcbq1Lp1HGDS4uy4mgGaYZZR",
|
"UGPASp9buYGz5rwF+u4HeYz+AFINYDLhwrhhiXnyr3/884aZutKQoQLEQJZjvdMxCDQuJxMQCcoELwac",
|
||||||
"EMveiX6I4TM0eJTYGF7p/BRDX/wwuWaYZQiImoFAwIw13F0LoRpz34azukThI8RFLYLwmoXMQWd7Mz4Q",
|
"ZiCVS6K+/O3RC5MG/eriGjmY3TDF0RindxMSd8VfGZi2HVatLpwAAVQImJCHfT02VsmqXmygYO0Mm9lW",
|
||||||
"XwiiORNh6Ma3l7sJUX+nVHIE9+avPsjFRvQITsEEoNlwLDvd+7+8/Ru6wws7Ruotxq4C55E4r6cdf5Xu",
|
"wYOy4BhUFNQ+4aof9uoCuRcPYfafewKyc6L+1dXF0T7MUUVHrfXTVcN2zYV89Aj5ryQj5d/r6ghZok94",
|
||||||
"w+V2cF/ahVhx3BpW8N4T1M9tiVKXQR6cWIcQsj4EAqpTnyPtBfrX//v/VZqqTWzQf3JUu1WIbS3+sBq7",
|
"fVS0dSjHWJ1at44DTFqcHdczQDPMMgpi2TvRDzF8hgaPEhvDK52fYuiLHyY3DLMMAVEzEAiYsYa7ayFU",
|
||||||
"2SFSo6WHM/l2wsch7GIBxK5t3O+Q66C52xm1lz2hiYShbQn5IFnfZ2bqx0flWeh6eQBXu5kLYeQP12Ho",
|
"Y+7bcFaXKHyEuKhFEN6wkDnobG/GB+ILQTRnIgzd+vZytyHq74xKjuDB/NUHudiIHsEpmAA0G45lp3v/",
|
||||||
"xYnqdRd2zC/WZ7NoTzA+N48vAbK9jTlLooMprMRtrFwTen87ffcW1VpvrdZoZYpTPt3lVXtHbv3ikrwR",
|
"57d/Rfd4YcdIvcXYVeA8Ehf1tOOv0n243A7uS7sQK45bwwree4L6uS1R6jLIgxPrEELWh0BAdepzpL1A",
|
||||||
"FpDU9hEm7yKHaIiicakv+IMcrj4hAEk9sd6NtDWtUtdC4OUffaf/lx/QELmSQD4Sr5EqtpAK8k7EY+z5",
|
"//q//69KU7WJDfpPjmq3CrGtxR9WYzc7RGq09Hgm3074OIRdLIDYtY37DXIdNHc7o/ayJzSRMLQtIR8l",
|
||||||
"605V08Rzk7J2qbAInth+3Q979ALxnChjTLubaYHBehD6toVRW/Sd4HwnoX6Ng+jZBgdRYmqUUlNo0Uqs",
|
"6/vcTP30qDwPXS8P4Go3cyGM/OE6DL04Ub3uwo75xfpsFu0Jxhfm8RVAtrcxZ0l0MIWVuI2Va0Lvr2fv",
|
||||||
"ne313WuTSrUwtZ0mXOS91bC8pU6hv3DCvB9k5P6mxbeCFyU1El5osXrs+j0mXTbhPhPfQ6jJuNw5oeuu",
|
"3qJa663VGq1Mccqnu7xq78itX1ySN8ICkto+wuRd5BANUTQu9QV/kMPVJwQgqSfWu5G2plXqWgi8/IPv",
|
||||||
"HjJ+vWopG+FI8xDNzdO9GfKyHFtK1ZQ7J7LElPzd1XIzPVDR75DpgbqDeV8zXtXjtI3zfqQA6rVH64OB",
|
"9P/yAxoiVxLIR+I1UsUWUkHeiXiMPX/dqWqaeG5S1q4UFsET26/7YY9eIJ4TZYxp9zMtMFgPQt+2MGqL",
|
||||||
"tNmuNQJWO+CAscVmY65Vrp/WmvVNRzdEWKa3wsU+ZrxQaHsoAYu0HdKX5nHozdnNYPFrb1kJ2M8K8BXo",
|
"vhOc7yTUr3EQPdvgIEpMjVJqCi1aibWzvb57bVKpFqa204SLvLcalrfUKfRnTpj3g4zc37T4VvCipEbC",
|
||||||
"9o3upIfzv70m6hCK+o8lpQOTP2LRaYu8BiRXXuq+FwFkglzHzwaLhle2oqFPwfvRISarTku/NXS+Ne1n",
|
"Cy1Wj12/x6TLJtxn4nsINRmXOyd03dVjxq9XLWUjHGkeorl5ujdDXpVjS6macudElpiSv7tabqYHKvoN",
|
||||||
"K8AfomIHpUFuk0OPM2Qb3SLFozGqXdEY52bTaDbq2erO1EYzskVpN3jn3rlBnU6Wzm62Dvd8KJl7EFEl",
|
"Mj1QdzDva8arepy2cd6PFEC99mh9NJA227VGwGoHHDC22GzMtcr101qzvunohgjL9Fa42MeMFwptDyVg",
|
||||||
"Lqf5lpmR+ANcKl6LP2g2ULUtPnZLuN7FifeYR2ujR+3heNFOiyQcqGaiIVaTkTewc6I8UO6u13Bd0Ws7",
|
"kbZD+so8Dr05uxksfuktKwH7WQG+At2+0Z30cP6310QdQlH/saR0YPJHLDptkdeA5MpL3fcigEyQ6/jZ",
|
||||||
"Nd9XutgBUeQYSUNx27wP0yApwhIhWK77hI120LGO3LU4ki3XutLSaRNlNSAS/XRji11IMKDuMDc88uRi",
|
"YNHwylY09Cl4PzrEZNVp6deGzrem/WwF+ENU7KA0yG1y6HGGbKNbpHg0RrUrGuPcbBrNRj1b3ZnaaEa2",
|
||||||
"zMq13C19mdeW6/9WW22NRP2aulOnjZhZDAIC4ib9ekgtwtKlMI/yUjnFIBjeNefgQTCH3s2AoSrCdsUa",
|
"KO0G79w7N6jTydLZzdbhng8lcw8iqsTlNN8yMxJ/gEvFa/EHzQaqtsXHbgnXuzjxnvJobfSoPRwv2mmR",
|
||||||
"Xk+kubLa5VecTKNX+JgJNZbY1xboefbkWQc6tEbyeqXPvY22SiswagYVJRvFxubs1wi6O7027TVRih1+",
|
"hAPVTDTEajLyBnZOlAfK3fUarit6bafm+0oXOyCKHCNpKG6b92EaJEVYIgTLdZ+w0Q461pG7Fkey5VpX",
|
||||||
"0tdxrNRPRNpxKX5bCDqt4e2vschQBhSUKQDPuEKyLAouTBX3makL77rpSgT3RNp6BaEfSEjhtzEFL59H",
|
"WjptoqwGRKKfbmyxCwkG1B3mhkeeXIxZuZa7pS/z2nL932qrrZGoX1N36rQRM4tBQEDcpF8PqUVYuhTm",
|
||||||
"WKMWer4bZ3yR8HO9tEcMQW/jiFrhoUfiiFrBooD1KlRyH05wVYnXByJc+EHbyN57FNfcMaKgW/zDbymS",
|
"UV4qpxgEw7vmHDwI5tD7GTBURdiuWMPriTTXVrv8ipNp9AqfMqHGEvvaAj3PTp51oENrJK9X+tzbaKu0",
|
||||||
"wLfRf7w4gkAaB4oiKCpS8/RMwXWS2yyJ+LcPWrMt3ukESTwBtUBzTOfgjt7LV384OkanocC3Ps6LurSz",
|
"AqNmUFGyUWxszn6NoLvTa9NeE6XY4Sd9HcdK/USkHZfit4Wg0xre/hqLDGVAQZkC8IwrJMui4MJUcZ+Z",
|
||||||
"Iupcftd2WF+EXvRf/qRukmRrqeVfSywwU6ZRVbQjz2rPq6rhf63ZVa2zlfEjhTExtfZxyywHhvsab4mr",
|
"uvCum65E8ECkrVcQ+oGEFH4bU/DyeYQ1aqHnu3HGFwk/10t7whD0No6oFR56Io6oFSwKWK9CJffhBFeV",
|
||||||
"KiEJe0ZCfVdvHtAQVbBFw+omOerOa0t3h4uzCclvJYVuxf0/lBRk7yuoS68Xcsg0CbOvA9eZR6JsqmaV",
|
"eH0gwqUftI3svUdxzR0jCrrFP/yaIgl8G/2niyMIpHGgKIKiIjVPzxRcJ7nNkoh/+6A12+KdTpDEE1AL",
|
||||||
"e3WHgKqzoFw1GhSEr70wcrf+JHLNIW0RJzxRIFbzuv3J97xVH2uA+ivVyOpr3EYnexQ2v7DxD04balAJ",
|
"NMd0Du7ovXr1u6NjdBYKfOvjvKhLOyuiztX3bYf1ZehF/+VP6iZJtpZa/qXEAjNlGlVFO/Ks9ryqGv7X",
|
||||||
"6mclpoOIM3stzXRg64eugroflTywdvJvSh6GIhx7H5IwXFzlOmPkqRtzCUoRNn3cs765lgMe92F3hyi4",
|
"ml3VOlsZP1IYE1Nrn7bMcmC4r/GWuK4SkrBnJNR39eYBDVEFWzSsbpKj7ry2dHe4OJuQ/FZS6Fbc/0NJ",
|
||||||
"bheJpJsT9W8JpQN5R1Q6SxCDOYiBr7lqKtoc7XAlxGXaD5hIE+foF0EkqtMLhQz1nz15hn5XhUIeo7f8",
|
"Qfa+grr0eiGHTJMw+zpwnXkkyqZqVrlXdwioOg/KVaNBQfjaCyN3608i1xzSFnHCEwViNa/bn3zPW/Wx",
|
||||||
"DkzxI6Js+oNbOrqZUj7G9FhPN8KpOkHXPT6ZXPdutAaLMxtTabc08oPQLbgsCn/tkDyHjGAFdKG//uTo",
|
"Bqi/Uo2svsZtdLInYfNLG//gtKEGlaB+VmI6iDiz19JMB7Z+7Cqo+1HJI2sn/6bkYSjCsfchCcPFVa4z",
|
||||||
"xFxNNbDYUpxmHnSHXXwMZuuLjpjTJkaeu50bejv6EaYXDRpt8ww9nNz6dfLIqcGmTdhRgthQ7UcUksPx",
|
"Rp65MVegFGHTpz3rm2s54HEfdneIgut2kUi6OVH/jlA6kPdEpbMEMZiDGPiaq6aizdEOV0Jcpv2AiTRx",
|
||||||
"6GndV6dsnJAvGmT2/scfNUsEgtzv/BRE3g5MwO8GafkDkbdnbtxjphT7ZRxSUCbyFnkYHEheFvU5tzwa",
|
"jn4RRKI6vVDIUP/ZyTP0myoU8hi95fdgih8RZdMf3NLR7ZTyMabHeroRTtUpuunxyeSmd6s1WJzZmEq7",
|
||||||
"NXoaycj2kKRgVd/lgr1ZI826W3KOaSm5bcDL2mSdvWeqheFtZcheIsXvYhVsa24mYIfpOXTOMi3V1Kfu",
|
"pZEfhO7AZVH4a4fkOWQEK6AL/fWTo1NzNdXAYktxmnnQPXbxMZitLzpiTpsYee52bujt6EeYXjZotM0z",
|
||||||
"S1DSloEZKW51FxPJQiS6hcLeCDOT17g42qUsR5sade5aVlofWqQQzapfEPVnBAQW6WwxwHdYwNELlGKR",
|
"9Hhy69fJI2cGmzZhRwliQ7WfUEgOx6OndV+dsnFCvmiQ2fsff9QsEQhyv/NTEHk3MAG/G6TlD0Tenbtx",
|
||||||
"EYapbds34SKFrE2RWk9zX4ciVV/j4zi3mgUQvkj/iQZFuoilneq/+J4D6y6FSzemc0YgHCzTPPTUZhPe",
|
"T5lS7JdxSEGZyDvkYXAgeVnU59zyaNToaSQj20OSglV9lwv2Zo00627JOaal5LYBL2uTdfaeqRaGt5Uh",
|
||||||
"S3p3zlSU9FJBFEmjvcYfJA++SyOg35KZ3+L8Ea38nugOVbk40PB2rXhqPGJTaHB6+yD5M6fprYN5HOvr",
|
"e4kUv49VsK25mYAdpufQBcu0VFOfui9BSVsGZqS41V1MJAuR6A4KeyPMTF7j4miXshxtatSFa1lpfWiR",
|
||||||
"d25fPVy7ktO0itDEDng7dippQC8vrXRzcPC9KxXU4HcIJ4Re66hkitCuFeBbe0Qud8SvZt6jieOXpQgN",
|
"QjSrfkHUnxEQWKSzxQDfYwFHL1CKRUYYprZt34SLFLI2RWo9zX0dilR9jU/j3GoWQPgi/ScaFOkilnaq",
|
||||||
"4EAKrsTK1dXbQxCFAMnp/GHo4oOd+8Ck0Y7mFWR+FchzUKjwl2NWYkoXu6JPq6obhAY7pFtrTGt+GZmo",
|
"/+J7Dqy7FK7cmM4ZgXCwTPPQU5tNeC/p3TtTUdJLBVEkjfYaf5Q8+C6NgH5NZn6L8ye08nuiO1Tl4kDD",
|
||||||
"0/947x/yWtdYecxb3VLFoS51Mxvqm5QqFRLrChD20dFuLn077UO7HywqvmJfe0EYg2zkoBqvibjqbteY",
|
"27XiqfGITaHB6d2j5M+cpXcO5nGsr9+5ffVw7UrO0ipCEzvg7dippAG9vLTSzcHB965UUIPfIZwQeq2j",
|
||||||
"aPW1f33edccQX71v3dCkafBE9K8eKTu60WsUPnRTdTjN/+pHfn0H2M4HUtjT/vhyU3nbj6ktaPG2/TG0",
|
"kilCu1aAb+0RudwRv5p5jyaOX5YiNIADKbgSK9fXbw9BFAIkp/PHoYsPdu4Dk0Y7mleQ+VUgz0Ghwl+O",
|
||||||
"Z96/SceTXZOfrszorY+if7dkDrPNA5KOA9shGB1YhjDDdCGJK19Iqc/hMJXJI4lU2yR0PGQyld4KpKUx",
|
"WYkpXeyKPq2qbhAa7JBurTGt+WVkok7/471/zGtdY+Upb3VLFYe61M1sqG9SqlRIrCtA2EdHu7n07bSP",
|
||||||
"3eipx4AFiNNSzXonP3/UGLfd2O2HS0F7J70hLshw/tTQg9vPatsml9zv8s5DXoEpOWtK4dRt581t2LSa",
|
"7X6wqPiKfe0FYQyykYNqvCbiqrtdY6LV1/71edcdQ3z1vnVDk6bBE9G/eqTs6EavUfjQTdXhNP+LH/n1",
|
||||||
"lTgU23kNQuu3pOptRaSts0w4S3ybo1rhKNfLaHXO8+1SHdx8vMq++BS3e5gtuuJCfYtq4wNqdOeMLijU",
|
"HWA7H0hhT/vjy03lbT+mtqDF2/bH0J55/yYdT3ZNfro2o7c+iv7dkjnMNg9IOg5sh2B0YBnCDNOFJK58",
|
||||||
"oKh6K7hOjUnwUkrUzyAlGQxxqmrTQr1E06eWuEuztCB66fOsNkM431bfrztgkqVQoyQ4x6qpnBdldaKQ",
|
"IaU+h8NUJo8kUm2T0PGYyVR6K5CWxnSjpx4DFiDOSjXrnf7to8a47cZuP1wK2jvtDXFBhvPvDD24/ay2",
|
||||||
"IelIw+UJV7a6Wo7jp2jqlUxsxrL5bkZU4qoPJihk43tMNbgsBu6CC7X6nqvk8Pnj5/8OAAD//2XM3suJ",
|
"bXLJ/S7vPOQVmJKzphRO3Xbe3IZNq1mJQ7Gd1yC0fkuq3lZE2jrLhLPEtzmqFY5yvYxW57zYLtXBzcer",
|
||||||
"7gAA",
|
"7ItPcbuH2aIrLtS3qDY+oEZ3zuiCQg2KqreC69SYBC+lRP0MUpLBEKeqNi3USzR9aom7NEsLopc+z2oz",
|
||||||
|
"hPNt9f26AyZZCjVKgnOsmsp5UVYnChmSjjRcnnBlq6vlOH6Kpl7JxGYsm+9mRCWu+mCCQja+x1SDy2Lg",
|
||||||
|
"LrhQq++5Sg6fP37+/wEAAP//VvsxiT/wAAA=",
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSwagger returns the content of the embedded swagger specification file
|
// GetSwagger returns the content of the embedded swagger specification file
|
||||||
|
|||||||
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[:])
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -24,7 +25,12 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultLimit = 50
|
defaultLimit = 50
|
||||||
maxLimit = 200
|
maxLimit = 200
|
||||||
graphNodeCap = 500
|
// graphNodeCap bounds the whole-graph view. The cognition transactional
|
||||||
|
// types (execution, task) are audit records, not topology, and previously
|
||||||
|
// crowded out every host/lxc/service; the default whole-graph view below
|
||||||
|
// excludes them so the cap is spent on the actual fleet graph. Operators
|
||||||
|
// still reach executions/tasks via list_entities.
|
||||||
|
graphNodeCap = 2000
|
||||||
)
|
)
|
||||||
|
|
||||||
// actorInfo returns the caller's (type, label) from the request context,
|
// actorInfo returns the caller's (type, label) from the request context,
|
||||||
@@ -60,19 +66,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 +192,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 +288,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,21 +306,26 @@ 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
|
||||||
// alphabetically. Without this the cap fills with exec:* rows and
|
// alphabetically. Without this the cap fills with exec:* rows and
|
||||||
// drops every host/lxc/service/vm — and every edge those entities
|
// drops every host/lxc/service/vm — and every edge those entities
|
||||||
// connect — because edges require both endpoints in the node set.
|
// connect — because edges require both endpoints in the node set.
|
||||||
|
// Exclude the cognition transactional types (execution/task): they
|
||||||
|
// are audit records rather than topology, and at ~380 rows they
|
||||||
|
// consumed most of the old 500-node cap.
|
||||||
nodes, err = s.queryEntities(ctx, `
|
nodes, err = s.queryEntities(ctx, `
|
||||||
SELECT `+entityCols+`
|
SELECT `+entityCols+`
|
||||||
FROM entities e
|
FROM entities e
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
WHERE e.id IN (
|
WHERE e.type NOT IN ('execution','task')
|
||||||
|
AND e.id IN (
|
||||||
SELECT e2.id FROM entities e2
|
SELECT e2.id FROM entities e2
|
||||||
LEFT JOIN relationships r ON r.valid_to IS NULL
|
LEFT JOIN relationships r ON r.valid_to IS NULL
|
||||||
AND (r.source_id = e2.id OR r.target_id = e2.id)
|
AND (r.source_id = e2.id OR r.target_id = e2.id)
|
||||||
|
WHERE e2.type NOT IN ('execution','task')
|
||||||
GROUP BY e2.id
|
GROUP BY e2.id
|
||||||
ORDER BY count(r.type) DESC, e2.slug
|
ORDER BY count(r.type) DESC, e2.slug
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
@@ -336,21 +345,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 +440,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
|
resp.EntityTypes = append(resp.EntityTypes, gen.EntityType{
|
||||||
var schema map[string]any
|
Name: et.Name,
|
||||||
if len(schemaJSON) > 0 && json.Unmarshal(schemaJSON, &schema) == nil && schema != nil {
|
ParentType: et.ParentType,
|
||||||
et.AttributeSchema = &schema
|
IsAbstract: et.IsAbstract,
|
||||||
}
|
Domain: et.Domain,
|
||||||
resp.EntityTypes = append(resp.EntityTypes, et)
|
Layer: gen.EntityTypeLayer(et.Layer),
|
||||||
}
|
Description: et.Description,
|
||||||
rows.Close()
|
LifecycleId: et.LifecycleID,
|
||||||
if rows.Err() != nil {
|
SchemaVersion: &schemaVersion,
|
||||||
return nil, rows.Err()
|
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,
|
||||||
resp.Lifecycles = append(resp.Lifecycles, lc)
|
Transitions: transitions,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
rows.Close()
|
|
||||||
return resp, rows.Err()
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Signals ──────────────────────────────────────────────────────────
|
// ─── Signals ──────────────────────────────────────────────────────────
|
||||||
@@ -807,7 +818,7 @@ func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject
|
|||||||
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id, ts, actor_type, actor_id::text, action, entity_id::text,
|
SELECT id, ts, actor_type, actor_id::text, action, entity_id::text,
|
||||||
method, path, status_code, detail, source_ip, correlation_id
|
method, path, status_code, detail, source_ip, correlation_id, session_id::text
|
||||||
FROM audit_log
|
FROM audit_log
|
||||||
WHERE ($1::text IS NULL OR actor_type = $1)
|
WHERE ($1::text IS NULL OR actor_type = $1)
|
||||||
AND ($2::text IS NULL OR actor_id::text = $2)
|
AND ($2::text IS NULL OR actor_id::text = $2)
|
||||||
@@ -828,10 +839,10 @@ func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a gen.AuditEntry
|
var a gen.AuditEntry
|
||||||
var detailBytes []byte
|
var detailBytes []byte
|
||||||
var actID, entID, method, path, sourceIP, corrID *string
|
var actID, entID, method, path, sourceIP, corrID, sessionID *string
|
||||||
var statusCode *int
|
var statusCode *int
|
||||||
if err := rows.Scan(&a.Id, &a.Ts, &a.ActorType, &actID, &a.Action, &entID,
|
if err := rows.Scan(&a.Id, &a.Ts, &a.ActorType, &actID, &a.Action, &entID,
|
||||||
&method, &path, &statusCode, &detailBytes, &sourceIP, &corrID); err != nil {
|
&method, &path, &statusCode, &detailBytes, &sourceIP, &corrID, &sessionID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
a.ActorId = actID
|
a.ActorId = actID
|
||||||
@@ -989,6 +1000,7 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
|||||||
entityID := inserted.ID
|
entityID := inserted.ID
|
||||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
|
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
|
||||||
&entityID, "POST", "/api/v1/entities", "",
|
&entityID, "POST", "/api/v1/entities", "",
|
||||||
|
nil,
|
||||||
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
|
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
|
||||||
return nil, auditErr
|
return nil, auditErr
|
||||||
}
|
}
|
||||||
@@ -999,7 +1011,9 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
|||||||
return nil, eventErr
|
return nil, eventErr
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureDefaultChecks(ctx, tx, inserted.ID, slug, req.Body.Type, attrsJSON)
|
if err := ensureDefaultChecks(ctx, tx, inserted.ID, slug, req.Body.Type, inserted.Name, attrsJSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1050,49 +1064,15 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
|
|||||||
|
|
||||||
// Validate lifecycle transition if state is being changed.
|
// Validate lifecycle transition if state is being changed.
|
||||||
if req.Body.State != nil && *req.Body.State != "" {
|
if req.Body.State != nil && *req.Body.State != "" {
|
||||||
// Get lifecycle def for the entity's type.
|
fromState := ""
|
||||||
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, current.Type)
|
if current.State != nil {
|
||||||
if err != nil {
|
fromState = *current.State
|
||||||
if err == pgx.ErrNoRows {
|
}
|
||||||
// No lifecycle defined — any state is allowed.
|
if err := db.ValidateTransition(ctx, tx, id, current.Type, fromState, *req.Body.State); err != nil {
|
||||||
} else {
|
if errors.Is(err, db.ErrTransitionInvalid) {
|
||||||
return nil, err
|
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidTransition, err)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var transitions map[string]map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse lifecycle transitions: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fromState := ""
|
|
||||||
if current.State != nil {
|
|
||||||
fromState = *current.State
|
|
||||||
}
|
|
||||||
toState := *req.Body.State
|
|
||||||
|
|
||||||
if toState != fromState {
|
|
||||||
tos, ok := transitions[fromState]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%w: no transitions from %q", domain.ErrInvalidTransition, fromState)
|
|
||||||
}
|
|
||||||
trans, ok := tos[toState]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse preconditions: {"requires": ["check-name", ...]}
|
|
||||||
var gate struct {
|
|
||||||
Requires []string `json:"requires"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(trans, &gate); err == nil && len(gate.Requires) > 0 {
|
|
||||||
for _, check := range gate.Requires {
|
|
||||||
if err := checkPrecondition(ctx, tx, id, current.Type, check); err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: precondition %q not met: %v",
|
|
||||||
domain.ErrInvalidTransition, check, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,6 +1111,7 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
|
|||||||
patchActorType, patchActor := actorInfo(ctx)
|
patchActorType, patchActor := actorInfo(ctx)
|
||||||
if auditErr := observability.Audit(ctx, q, patchActorType, patchActor, "patch",
|
if auditErr := observability.Audit(ctx, q, patchActorType, patchActor, "patch",
|
||||||
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
|
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
|
||||||
|
nil,
|
||||||
map[string]any{"version": expectedVersion}); auditErr != nil {
|
map[string]any{"version": expectedVersion}); auditErr != nil {
|
||||||
return nil, auditErr
|
return nil, auditErr
|
||||||
}
|
}
|
||||||
@@ -1260,12 +1241,15 @@ func (s *Server) EnrollClient(ctx context.Context, req gen.EnrollClientRequestOb
|
|||||||
entityID := id
|
entityID := id
|
||||||
_ = observability.Audit(ctx, q, "operator", actor, "enroll",
|
_ = observability.Audit(ctx, q, "operator", actor, "enroll",
|
||||||
&entityID, "POST", "/api/v1/clients/enroll", "",
|
&entityID, "POST", "/api/v1/clients/enroll", "",
|
||||||
|
nil,
|
||||||
map[string]any{"slug": req.Body.Slug, "mesh_ip": meshIP})
|
map[string]any{"slug": req.Body.Slug, "mesh_ip": meshIP})
|
||||||
_ = observability.Event(ctx, q, "client.enrolled", &entityID,
|
_ = observability.Event(ctx, q, "client.enrolled", &entityID,
|
||||||
"info", "oikos-api", "",
|
"info", "oikos-api", "",
|
||||||
map[string]any{"slug": req.Body.Slug, "type": current.Type})
|
map[string]any{"slug": req.Body.Slug, "type": current.Type})
|
||||||
|
|
||||||
ensureDefaultChecks(ctx, tx, id, req.Body.Slug, current.Type, attrsJSON)
|
if err := ensureDefaultChecks(ctx, tx, id, req.Body.Slug, current.Type, current.Name, attrsJSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1452,6 +1436,7 @@ func (s *Server) ProvisionEntity(ctx context.Context, req gen.ProvisionEntityReq
|
|||||||
_, actor := actorInfo(ctx)
|
_, actor := actorInfo(ctx)
|
||||||
_ = observability.Audit(ctx, q, "operator", actor, "provision",
|
_ = observability.Audit(ctx, q, "operator", actor, "provision",
|
||||||
&entityID, "POST", "/api/v1/entities/provision", "",
|
&entityID, "POST", "/api/v1/entities/provision", "",
|
||||||
|
nil,
|
||||||
map[string]any{"slug": req.Body.Slug, "host": hostSlug})
|
map[string]any{"slug": req.Body.Slug, "host": hostSlug})
|
||||||
_ = observability.Event(ctx, q, "entity.provisioned", &entityID,
|
_ = observability.Event(ctx, q, "entity.provisioned", &entityID,
|
||||||
"info", "oikos-api", "",
|
"info", "oikos-api", "",
|
||||||
@@ -1542,98 +1527,5 @@ func generateAgeKeypair() (pubKey, privKey string, err error) {
|
|||||||
return pub, priv, nil
|
return pub, priv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkPrecondition validates a named lifecycle transition precondition.
|
|
||||||
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
|
|
||||||
switch check {
|
|
||||||
case "no-inbound-edges":
|
|
||||||
var count int
|
|
||||||
err := tx.QueryRow(ctx,
|
|
||||||
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return fmt.Errorf("%d inbound relationship edges remaining", count)
|
|
||||||
}
|
|
||||||
case "backups-verified":
|
|
||||||
var attrs string
|
|
||||||
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !strings.Contains(attrs, "backups_verified") {
|
|
||||||
return fmt.Errorf("backup verification not recorded in entity attributes")
|
|
||||||
}
|
|
||||||
case "secrets-revoked":
|
|
||||||
var attrs string
|
|
||||||
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !strings.Contains(attrs, "secrets_revoked") {
|
|
||||||
return fmt.Errorf("secret revocation not recorded in entity attributes")
|
|
||||||
}
|
|
||||||
case "ingress-dns-removed":
|
|
||||||
var attrs string
|
|
||||||
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !strings.Contains(attrs, "ingress_dns_removed") {
|
|
||||||
return fmt.Errorf("ingress/DNS removal not recorded in entity attributes")
|
|
||||||
}
|
|
||||||
case "age-key-enrolled-if-needed":
|
|
||||||
if entityType == "workstation" {
|
|
||||||
var attrs string
|
|
||||||
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !strings.Contains(attrs, "age_pubkey") {
|
|
||||||
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "mesh-joined-if-needed":
|
|
||||||
if entityType == "workstation" {
|
|
||||||
var attrs string
|
|
||||||
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !strings.Contains(attrs, "mesh_ip") {
|
|
||||||
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "health-check-answering":
|
|
||||||
var health string
|
|
||||||
err := tx.QueryRow(ctx, "SELECT health FROM entity_status WHERE entity_id = $1", entityID).Scan(&health)
|
|
||||||
if err != nil || health == "unknown" || health == "down" {
|
|
||||||
return fmt.Errorf("health check not answering (status: %s)", health)
|
|
||||||
}
|
|
||||||
case "doc-page-complete":
|
|
||||||
var count int
|
|
||||||
err := tx.QueryRow(ctx, `
|
|
||||||
SELECT count(*) FROM relationships r
|
|
||||||
JOIN entities ke ON ke.id = r.source_id
|
|
||||||
WHERE r.target_id = $1 AND r.valid_to IS NULL
|
|
||||||
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
|
|
||||||
entityID).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
return fmt.Errorf("no documentation linked to entity")
|
|
||||||
}
|
|
||||||
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
|
|
||||||
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
|
|
||||||
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
|
|
||||||
"ingress-live-if-public", "doc-page-stub":
|
|
||||||
// Soft checks — always pass. These are operator-confirmed via the
|
|
||||||
// transition request itself, or are not mechanically enforceable.
|
|
||||||
default:
|
|
||||||
// Unknown preconditions are skipped (operator intent overrides).
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────────
|
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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.TrendDirectionImproving
|
||||||
|
} else if f.Float64 < -0.01 {
|
||||||
|
t.Direction = gen.TrendDirectionDegrading
|
||||||
|
} else {
|
||||||
|
t.Direction = gen.TrendDirectionStable
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Direction = gen.TrendDirectionUnknown
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
124
internal/httpapi/patterns.go
Normal file
124
internal/httpapi/patterns.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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, "",
|
||||||
|
nil,
|
||||||
|
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
122
internal/httpapi/relationships.go
Normal file
122
internal/httpapi/relationships.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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", "",
|
||||||
|
nil,
|
||||||
|
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", "",
|
||||||
|
nil,
|
||||||
|
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,36 @@ 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/executions/{id}/logs — streamed command output, no schema type
|
||||||
|
// /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 +203,61 @@ 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)
|
||||||
|
|
||||||
|
// Drift audit: read-only DB-side report of orphan checks, checks on
|
||||||
|
// retired targets, stuck down/unknown probes, unmonitored declared types,
|
||||||
|
// and dangling edges. Companion to the knowledge-graph-audit skill.
|
||||||
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/audit/drift", s.serveAuditDrift)
|
||||||
|
|
||||||
// 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)
|
||||||
|
// Streamed command output for one execution — a projection over
|
||||||
|
// execution_logs with no schema type yet (same carve-out rationale as
|
||||||
|
// /activity/recent above). Nests cleanly under the generated
|
||||||
|
// /executions/{id} subtree: chi accepts sibling children on a param node.
|
||||||
|
r.With(combinedAuth(cfg, false)).Get("/api/v1/executions/{id}/logs", s.serveExecutionLogs)
|
||||||
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)
|
||||||
|
|
||||||
@@ -326,10 +394,10 @@ func staticTokenActor(cfg config.Config, raw string) (actor, bool) {
|
|||||||
// jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
|
// jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
|
||||||
// verification, identified by its key ID (kid).
|
// verification, identified by its key ID (kid).
|
||||||
type jwtVerificationKey struct {
|
type jwtVerificationKey struct {
|
||||||
Kid string
|
Kid string
|
||||||
Alg string
|
Alg string
|
||||||
Key any // *rsa.PublicKey or []byte for HMAC
|
Key any // *rsa.PublicKey or []byte for HMAC
|
||||||
IsHMAC bool
|
IsHMAC bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// discoverJWKSURI fetches the OIDC discovery document and extracts the
|
// discoverJWKSURI fetches the OIDC discovery document and extracts the
|
||||||
@@ -575,8 +643,8 @@ func resolveOIDCTokenURL(issuer string) string {
|
|||||||
func (s *Server) serveOIDCConfig(w http.ResponseWriter, _ *http.Request, cfg config.Config) {
|
func (s *Server) serveOIDCConfig(w http.ResponseWriter, _ *http.Request, cfg config.Config) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
"issuer": cfg.OIDCIssuer,
|
"issuer": cfg.OIDCIssuer,
|
||||||
"client_id": cfg.OIDCClientID,
|
"client_id": cfg.OIDCClientID,
|
||||||
"authorization_endpoint": resolveOIDCEndpointURL(cfg.OIDCIssuer, "/authorize/"),
|
"authorization_endpoint": resolveOIDCEndpointURL(cfg.OIDCIssuer, "/authorize/"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
197
internal/httpapi/skills.go
Normal file
197
internal/httpapi/skills.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
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, "",
|
||||||
|
nil,
|
||||||
|
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 {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user