Compare commits
93 Commits
7e1ccad5f4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 23c8144436 | |||
| 64f7d54011 | |||
| d4d99a7473 | |||
| e074f04bdf | |||
| ec119566fd | |||
| 809c16f6fd | |||
|
|
e7cc57a929 | ||
|
|
9ec05e2e3f | ||
| 8fbe39cf2a | |||
| 7160eee1e1 | |||
| 30ecdc16c2 | |||
| d79b0862bd | |||
| 53823595de | |||
| 7ecf720166 | |||
| febc153b7f | |||
| 7d6a3320d4 | |||
| 60bc9d555d | |||
| ebe1b95acf | |||
| 5e10437fe3 | |||
| 7236c46e5c | |||
| 75c0848a6f | |||
| 712b66422b | |||
| a30c024ef8 | |||
| 137a2afb8d | |||
| c8b1ec5af2 | |||
| 8ff382a50d | |||
| fa79c1ea25 | |||
| ef762794e7 | |||
| 0b6c546aae | |||
| cecfd8b0e4 | |||
| 653ea3a116 | |||
| 89a94c24c9 | |||
| 7a34d8c8a0 | |||
| c9d506b0f8 | |||
| e3449b24c1 | |||
| 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 |
@@ -30,9 +30,9 @@ one pass through **Observe → Orient → Decide → Act**:
|
|||||||
- **queue**: informational — console + reports
|
- **queue**: informational — console + reports
|
||||||
The classifier can only *lower* autonomy relative to policy, never raise
|
The classifier can only *lower* autonomy relative to policy, never raise
|
||||||
it. When in doubt, escalate.
|
it. When in doubt, escalate.
|
||||||
4. **Act** — execute through `homelab` commands or runbooks (never ad-hoc
|
4. **Act** — execute through MCP `run` or runbooks (never ad-hoc
|
||||||
SSH), then **verify** with the action's verification command, write a
|
SSH), then **verify** with the action's verification command, write a
|
||||||
**ledger** entry, resolve the Signal, and update docs in the same session.
|
**ledger** entry, resolve the Signal, and update docs in the same session.
|
||||||
|
|
||||||
## Primitives
|
## Primitives
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ via the API's `/api/v1/graph` endpoint, and the Mermaid export at
|
|||||||
## Conventions carried forward
|
## Conventions carried forward
|
||||||
|
|
||||||
- Inventory is the truth; live state wins over narrative docs.
|
- Inventory is the truth; live state wins over narrative docs.
|
||||||
- Prefer `homelab` CLI and MCP over ad-hoc SSH.
|
- Prefer MCP tools over ad-hoc SSH.
|
||||||
- Meaningful changes update docs in the same session.
|
- Meaningful changes update docs in the same session.
|
||||||
- Secrets are decrypted locally via per-client keys; never into docs/comments.
|
- Secrets are decrypted locally via per-client keys; never into docs/comments.
|
||||||
- Tracked configs change by commit + push, not local edits.
|
- Tracked configs change by commit + push, not local edits.
|
||||||
@@ -141,7 +141,7 @@ in the Go binary.
|
|||||||
- 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 the MCP tool
|
(:8092). Structured queries + natural-language routing to the MCP tool
|
||||||
list (see AGENTS.md §3). Agent activity logging on every tool call. No SSH keys.
|
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 at `nomos/skills/homelab-ops/`.
|
||||||
- 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/`.
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ cmd/webhook/main.go Gitea deploy-webhook receiver (push-to-deploy on mac-
|
|||||||
internal/httpapi/ REST + MCP server. Chi router. OpenAPI-generated types from
|
internal/httpapi/ REST + MCP server. Chi router. OpenAPI-generated types from
|
||||||
internal/httpapi/gen/api.gen.go. Strict server in impl.go.
|
internal/httpapi/gen/api.gen.go. Strict server in impl.go.
|
||||||
internal/mcp/ MCP tool implementations (get_entity, search_knowledge, etc.)
|
internal/mcp/ MCP tool implementations (get_entity, search_knowledge, etc.)
|
||||||
internal/db/ Connection pool (pool.go), seed ingestion (seed.go), DB→YAML
|
internal/adapters/postgres/ Connection pool (pool.go), seed ingestion (seed.go), DB→YAML
|
||||||
export (export.go), type hierarchy (typetree.go)
|
export (export.go), type hierarchy (typetree.go)
|
||||||
internal/db/queries/ SQL query files → sqlc generates internal/db/sqlcgen/
|
internal/adapters/postgres/queries/ SQL query files → sqlc generates sqlcgen/ (same dir)
|
||||||
internal/scheduler/ Observe loop: probes, signals, check_defs
|
internal/scheduler/ Observe loop: probes, signals, check_defs
|
||||||
internal/actuator/ SSH execution with circuit breaker + retry
|
internal/actuator/ SSH execution with circuit breaker + retry
|
||||||
internal/learning/ Pattern extraction, anomaly detection
|
internal/learning/ Pattern extraction, anomaly detection
|
||||||
@@ -27,8 +27,6 @@ internal/domain/ Core types: entities, approvals, executions, signals
|
|||||||
internal/ontology/ Type hierarchy validation, relationship checks
|
internal/ontology/ Type hierarchy validation, relationship checks
|
||||||
internal/knowledge/ Knowledge YAML seed ingestion
|
internal/knowledge/ Knowledge YAML seed ingestion
|
||||||
internal/config/ Config loading from env vars
|
internal/config/ Config loading from env vars
|
||||||
web/ Control-room SPA (Svelte 5) — standalone static build, not
|
|
||||||
embedded in the oikos binary (plans/2026-07-12-wails-desktop-app.md)
|
|
||||||
api/openapi.yaml REST API contract. Source of truth for endpoints.
|
api/openapi.yaml REST API contract. Source of truth for endpoints.
|
||||||
api/codegen.yaml oapi-codegen config → generates internal/httpapi/gen/
|
api/codegen.yaml oapi-codegen config → generates internal/httpapi/gen/
|
||||||
migrations/ Forward-only SQL. Format: NNN_name.up.sql. No down migrations.
|
migrations/ Forward-only SQL. Format: NNN_name.up.sql. No down migrations.
|
||||||
@@ -93,8 +91,8 @@ 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/adapters/postgres/queries/*.sql` with `-- name: FuncName :exec`
|
||||||
annotations for sqlc. Generated code in `internal/db/sqlcgen/` — never
|
annotations for sqlc. Generated code in `internal/adapters/postgres/sqlcgen/` — never
|
||||||
hand-edit. Call via `sqlcgen.New(pool).QueryName(ctx, params)`.
|
hand-edit. Call via `sqlcgen.New(pool).QueryName(ctx, params)`.
|
||||||
- **sqlc is the default** for all DB access. Raw `pool.Query/Exec` with inline
|
- **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`,
|
SQL is a documented carve-out for cases sqlc can't express: `LISTEN`/`NOTIFY`,
|
||||||
|
|||||||
@@ -81,8 +81,10 @@ the REST API. Closest current equivalents for what used to live here:
|
|||||||
| `homelab decide <action> <entity>` | No direct equivalent — classification now happens inline inside `run`, not as a separate dry-run call |
|
| `homelab decide <action> <entity>` | No direct equivalent — classification now happens inline inside `run`, not as a separate dry-run call |
|
||||||
|
|
||||||
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 is the operator dashboard. It lives in its own repo
|
||||||
[plans/done/2026-07-12-wails-desktop-app.md](../../plans/done/2026-07-12-wails-desktop-app.md)).
|
(`dtoro/oikos-web`, local checkout `~/Projects/oikos-web`) with its own
|
||||||
|
deploy pipeline, publishing the same host port 8091 as before (Phase 1 of
|
||||||
|
[plans/2026-08-15-hexagonal-architecture.md](../../plans/2026-08-15-hexagonal-architecture.md)).
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
- [Hubris host](../../archive/knowledge/hosts/hubris.md)
|
- [Hubris host](../../archive/knowledge/hosts/hubris.md)
|
||||||
|
|||||||
@@ -20,6 +20,6 @@ Run from the repo root:
|
|||||||
Exit code is non-zero when any violation is found, so it can gate a commit. The banned-vocabulary
|
Exit code is non-zero when any violation is found, so it can gate a commit. The banned-vocabulary
|
||||||
list mirrors `writing-style.md`; update both together if the standard changes.
|
list mirrors `writing-style.md`; update both together if the standard changes.
|
||||||
|
|
||||||
> **Known baseline.** `archive/knowledge/archive/knowledge/containers/101-jellyfin.md` links into a sibling repo
|
> **Known baseline.** `archive/knowledge/containers/101-jellyfin.md` links into a sibling repo
|
||||||
> (`devops/homelab-authentik-admin`) that this checkout does not contain — expected, not a bug.
|
> (`devops/homelab-authentik-admin`) that this checkout does not contain — expected, not a bug.
|
||||||
> Any other broken link is a real regression; investigate before dismissing it as baseline noise.
|
> Any other broken link is a real regression; investigate before dismissing it as baseline noise.
|
||||||
|
|||||||
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.
|
||||||
@@ -36,8 +36,9 @@ ledger entry.
|
|||||||
6. Update the entity's `state` to `destroyed` in `seeds/inventory.yaml`
|
6. Update the entity's `state` to `destroyed` in `seeds/inventory.yaml`
|
||||||
(or move it to an `archaeology:`-style section if the schema still has
|
(or move it to an `archaeology:`-style section if the schema still has
|
||||||
one) — `pve_id`, `destroyed` date, `reason` — then `oikos seed` to
|
one) — `pve_id`, `destroyed` date, `reason` — then `oikos seed` to
|
||||||
ingest. Add a row to `containers/index.md` "Recently destroyed" table
|
ingest. Add a row to the legacy `archive/knowledge/containers/index.md`
|
||||||
(kept for human-readable browsing alongside the structured data).
|
"Recently destroyed" table (kept for human-readable browsing
|
||||||
|
alongside the structured data in the DB).
|
||||||
7. No manual ledger step — mutations through the API are recorded
|
7. No manual ledger step — mutations through the API are recorded
|
||||||
automatically in the `audit_log` table (MCP `get_audit_trail`,
|
automatically in the `audit_log` table (MCP `get_audit_trail`,
|
||||||
`get_change_history`). The old `oikos/ledger.py append` was retired
|
`get_change_history`). The old `oikos/ledger.py append` was retired
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ chosen, doc page stub.
|
|||||||
will self-enroll as a client afterward (see
|
will self-enroll as a client afterward (see
|
||||||
[CLIENTS.md](../../../CLIENTS.md#enrollment)), the entity must exist in
|
[CLIENTS.md](../../../CLIENTS.md#enrollment)), the entity must exist in
|
||||||
`planned`/`provisioning` state before `bootstrap.sh` runs there.
|
`planned`/`provisioning` state before `bootstrap.sh` runs there.
|
||||||
3. Stub the doc page (`containers/<pve_id>-<name>.md` or
|
3. Stub a document entity via MCP `upsert_knowledge` with
|
||||||
`vms/<pve_id>-<name>.md`) — even a one-line "provisioning, see plan X"
|
`kind: document` and about set to the new entity slug — even a
|
||||||
is enough to satisfy the transition requirement.
|
one-line "provisioning, see plan X" is enough to satisfy the
|
||||||
|
transition requirement.
|
||||||
4. Reserve the IP in DNS/DHCP notes if it's a fixed LAN address.
|
4. Reserve the IP in DNS/DHCP notes if it's a fixed LAN address.
|
||||||
|
|
||||||
Next: [lifecycle-activate-node.md](../lifecycle-activate-node/SKILL.md).
|
Next: [lifecycle-activate-node.md](../lifecycle-activate-node/SKILL.md).
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ Session: {id[:8]} — "{title[:60]}"
|
|||||||
- `cmd/nomos/store.go` — session + message persistence
|
- `cmd/nomos/store.go` — session + message persistence
|
||||||
- `internal/mcp/server.go` — all tool implementations (`run`, `list_lxcs`, …)
|
- `internal/mcp/server.go` — all tool implementations (`run`, `list_lxcs`, …)
|
||||||
- `web/src/lib/components/ToolCallGroup.svelte` — tool result display
|
- `web/src/lib/components/ToolCallGroup.svelte` — tool result display
|
||||||
|
(in the `dtoro/oikos-web` repo, `~/Projects/oikos-web`, since the
|
||||||
|
Phase 1 client extraction)
|
||||||
- `nomos/SOUL.md` — agent persona and tool selection rules
|
- `nomos/SOUL.md` — agent persona and tool selection rules
|
||||||
- `plans/2026-07-09-chat-sessions-improvements.md` — prior session findings
|
- `plans/done/2026-07-09-chat-sessions-improvements.md` — prior session findings
|
||||||
- `plans/2026-07-09-session-execution-and-ux-fixes.md` — latest plan
|
- `plans/done/2026-07-09-session-execution-and-ux-fixes.md` — latest plan
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.0.1",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"runtimeExecutable": "sh",
|
|
||||||
"runtimeArgs": ["-c", "export OIKOS_API_TOKEN=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' oikos-api-1 | sed -n 's/^OIKOS_MCP_BEARER_TOKEN=//p'); exec npm --prefix web run dev"],
|
|
||||||
"port": 5173,
|
|
||||||
"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,30 +70,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
name: Desktop App
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
|
||||||
- 'desktop-*'
|
|
||||||
- 'v[0-9]+.[0-9]+.[0-9]*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build Linux (amd64)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- run: npm ci
|
|
||||||
working-directory: web
|
|
||||||
|
|
||||||
- run: npm run build
|
|
||||||
working-directory: web
|
|
||||||
|
|
||||||
- run: |
|
|
||||||
rm -rf cmd/desktop/frontend/dist
|
|
||||||
mkdir -p cmd/desktop/frontend/dist
|
|
||||||
cp -r web/dist/* cmd/desktop/frontend/dist/
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.26'
|
|
||||||
|
|
||||||
- run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
|
|
||||||
|
|
||||||
- run: CGO_ENABLED=1 go build -o build/bin/Oikos .
|
|
||||||
working-directory: cmd/desktop
|
|
||||||
|
|
||||||
- run: |
|
|
||||||
cd cmd/desktop/build/bin
|
|
||||||
tar czf oikos-desktop-linux-amd64.tar.gz Oikos
|
|
||||||
sha256sum oikos-desktop-linux-amd64.tar.gz > oikos-desktop-linux-amd64.tar.gz.sha256
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: oikos-desktop-linux-amd64
|
|
||||||
path: |
|
|
||||||
cmd/desktop/build/bin/oikos-desktop-linux-amd64.tar.gz
|
|
||||||
cmd/desktop/build/bin/oikos-desktop-linux-amd64.tar.gz.sha256
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: Attach to Release
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: oikos-desktop-linux-amd64
|
|
||||||
|
|
||||||
- uses: https://gitea.com/actions/release-action@v1
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
oikos-desktop-linux-amd64.tar.gz
|
|
||||||
oikos-desktop-linux-amd64.tar.gz.sha256
|
|
||||||
api_key: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -12,15 +12,8 @@ backups/
|
|||||||
.env
|
.env
|
||||||
.infisical-credentials
|
.infisical-credentials
|
||||||
|
|
||||||
# Web UI (Svelte 5) — build artifacts. The SPA is a standalone static build,
|
# Local tooling artifacts (agent worktrees, Playwright MCP logs, screenshots)
|
||||||
# deployed separately from the oikos binary (plans/2026-07-12-wails-desktop-app.md
|
.claude/
|
||||||
# 0.1), so the output dir is just a build artifact.
|
.playwright-mcp/
|
||||||
web/dist/
|
config-screen.png
|
||||||
web/node_modules/
|
|
||||||
|
|
||||||
# Wails desktop app — frontend copy for embedding
|
|
||||||
cmd/desktop/frontend/dist/
|
|
||||||
cmd/desktop/build/
|
|
||||||
cmd/desktop/Oikos
|
|
||||||
desktop
|
|
||||||
/eval
|
|
||||||
|
|||||||
114
.golangci.yml
114
.golangci.yml
@@ -1,41 +1,89 @@
|
|||||||
# golangci-lint configuration for Oikos
|
# golangci-lint v2 configuration for Oikos
|
||||||
# Docs: https://golangci-lint.run/usage/configuration/
|
# Docs: https://golangci-lint.run/usage/configuration/
|
||||||
|
# Default-enabled linters (errcheck, govet, ineffassign, staticcheck, unused)
|
||||||
|
# are not listed below. gosimple/typecheck were absorbed into staticcheck in v2.
|
||||||
|
version: "2"
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
|
||||||
tests: true
|
tests: true
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- govet # go vet
|
- depguard
|
||||||
- staticcheck # advanced static analysis
|
- misspell
|
||||||
- ineffassign # detect ineffectual assignments
|
- revive
|
||||||
- unused # find unused identifiers
|
settings:
|
||||||
- errcheck # check for unchecked errors
|
depguard:
|
||||||
- gosimple # simplifications
|
# ADR 0016 dependency rules. Rules only constrain files that exist:
|
||||||
- typecheck # standard type checking
|
# internal/core is live since Phase 0 (domain moved); internal/nomos
|
||||||
- misspell # find commonly misspelled English words in comments
|
# and its bans activate in Phase 8; full audit at Phase 9.
|
||||||
- revive # fast, configurable linter (replaces golint)
|
rules:
|
||||||
|
core-no-agent-tech:
|
||||||
linters-settings:
|
files:
|
||||||
errcheck:
|
- "**/internal/core/**"
|
||||||
# Allow unchecked errors on common Close/Flush patterns (deferred cleanup)
|
deny:
|
||||||
exclude-functions:
|
- pkg: github.com/dtoro/oikos/internal/nomos
|
||||||
- (io.Closer).Close
|
desc: core never links agent-client packages (ADR 0016 §3.1 rule 3)
|
||||||
- (*os.File).Close
|
- pkg: github.com/dtoro/oikos/internal/nomos/**
|
||||||
|
desc: core never links agent-client packages (ADR 0016 §3.1 rule 3)
|
||||||
|
- pkg: github.com/openai/openai-go
|
||||||
|
desc: core never links the LLM SDK — nomos is an external client
|
||||||
|
- pkg: github.com/openai/openai-go/**
|
||||||
|
desc: core never links the LLM SDK — nomos is an external client
|
||||||
|
- pkg: github.com/modelcontextprotocol/go-sdk
|
||||||
|
desc: core never links MCP packages — mcpserver is a driving adapter
|
||||||
|
- pkg: github.com/modelcontextprotocol/go-sdk/**
|
||||||
|
desc: core never links MCP packages — mcpserver is a driving adapter
|
||||||
|
core-purity:
|
||||||
|
files:
|
||||||
|
- "**/internal/core/**"
|
||||||
|
deny:
|
||||||
|
- pkg: github.com/dtoro/oikos/internal/adapters
|
||||||
|
desc: core must not import adapters — depend on core/ports instead
|
||||||
|
- pkg: github.com/dtoro/oikos/internal/adapters/**
|
||||||
|
desc: core must not import adapters — depend on core/ports instead
|
||||||
|
- pkg: github.com/dtoro/oikos/cmd
|
||||||
|
desc: core must not import composition roots
|
||||||
|
- pkg: github.com/dtoro/oikos/cmd/**
|
||||||
|
desc: core must not import composition roots
|
||||||
|
nomos-isolation:
|
||||||
|
files:
|
||||||
|
- "**/internal/nomos/**"
|
||||||
|
deny:
|
||||||
|
- pkg: github.com/dtoro/oikos/internal/core
|
||||||
|
desc: nomos must not import core — consume oikos via MCP/REST
|
||||||
|
- pkg: github.com/dtoro/oikos/internal/core/**
|
||||||
|
desc: nomos must not import core — consume oikos via MCP/REST
|
||||||
|
- pkg: github.com/dtoro/oikos/internal/adapters
|
||||||
|
desc: nomos must not import adapters — consume oikos via MCP/REST
|
||||||
|
- pkg: github.com/dtoro/oikos/internal/adapters/**
|
||||||
|
desc: nomos must not import adapters — consume oikos via MCP/REST
|
||||||
|
errcheck:
|
||||||
|
# Allow unchecked errors on common Close/Flush patterns (deferred cleanup)
|
||||||
|
exclude-functions:
|
||||||
|
- (io.Closer).Close
|
||||||
|
- (*os.File).Close
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
rules:
|
||||||
|
- linters:
|
||||||
|
- errcheck
|
||||||
|
path: _test\.go
|
||||||
|
- linters:
|
||||||
|
- all
|
||||||
|
path: internal/httpapi/gen/
|
||||||
|
- linters:
|
||||||
|
- all
|
||||||
|
path: internal/adapters/postgres/sqlcgen/
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
issues:
|
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-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
formatters:
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
|||||||
136
AGENTS.md
136
AGENTS.md
@@ -56,67 +56,88 @@ 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 (the authoritative list — count them below if a number is
|
Available tools (67 total — the authoritative list; do not hardcode the count
|
||||||
needed; do not hardcode the count elsewhere):
|
elsewhere; regenerate from `internal/mcp/` when tools change):
|
||||||
|
|
||||||
Context — observe + orient:
|
💡 Slug param alias: all entity-lookup tools now accept `slug` in addition to
|
||||||
get_entity(slug), list_entities(type, limit, cursor),
|
their declared param name (e.g. `get_entity(slug="lxc:seanime")` works).
|
||||||
get_relations(entity), get_blast_radius(entity),
|
|
||||||
search_knowledge(query) — ILIKE search over documents, investigations,
|
|
||||||
runbooks in the knowledge_entities table
|
|
||||||
get_entity_knowledge(entity_slug) — every document, investigation, and
|
|
||||||
runbook linked to one entity, in one call
|
|
||||||
get_patterns(status, entity_type, action) — learned action patterns
|
|
||||||
get_skills(status) — available automation skills
|
|
||||||
http_get(url) — fetch a public page/raw file (e.g. researching how to
|
|
||||||
deploy something before provisioning it); HTTP/HTTPS only, ~16KB cap
|
|
||||||
|
|
||||||
Management — live state:
|
Entity Tools — knowledge graph, discovery, and lifecycle:
|
||||||
get_service_status(service_slug) — systemctl is-active on target host
|
ping — lightweight connectivity check
|
||||||
tail_log(service_slug, lines=200) — journalctl
|
get_entity(slug_or_id) — get an entity by slug or UUID
|
||||||
list_lxcs() — all LXC containers with ID, host, IP, health
|
list_entities(type, state, q, limit) — entities filtered by type, state, or search
|
||||||
get_lxc_state(lxc_slug) — pct status from Proxmox host
|
get_relations(entity_id, types) — list inbound/outbound edges for one entity
|
||||||
ping_service(service_slug) — HTTP reachability from entity_status
|
get_blast_radius(entity_id, depth=3) — entities affected if this one goes down
|
||||||
list_my_secrets(caller_pubkey) — secrets accessible to this client by
|
create_entity(type, name, slug, attributes, state) — create a new entity in the graph
|
||||||
age public key
|
update_entity_attributes(slug, attributes) — merge discovered facts into an entity
|
||||||
|
set_entity_state(slug, state) — transition entity to a new lifecycle state
|
||||||
Oikos — decisions:
|
create_relationship(source, target, type) — record a discovered edge
|
||||||
|
end_relationship(source, target, type) — soft-delete an active edge
|
||||||
|
whoami(hostname) — entity record, peers, and health for a host
|
||||||
explain(service_slug) — compact context card (type, state, health, relations)
|
explain(service_slug) — compact context card (type, state, health, relations)
|
||||||
preflight(service_slug, action) — risk class + approval requirement
|
preflight(service_slug, action) — risk class + approval requirement
|
||||||
whoami(hostname) — entity record, peers, health for a client
|
|
||||||
get_change_history(entity_slug, limit=20) — last audit-log entries per entity
|
get_change_history(entity_slug, limit=20) — last audit-log entries per entity
|
||||||
get_state_snapshot() — fleet health, disk, drift count
|
get_state_snapshot() — last scheduler Observe-pass: fleet health, disk, drift
|
||||||
|
audit_knowledge_graph() — read-only drift report over the graph and checks
|
||||||
|
discover_infra_drift() — running guests vs DB: missing/ghost entities
|
||||||
|
find_entities_by(key, value, limit=25) — search entities by attribute values
|
||||||
|
|
||||||
Operations — observe + act:
|
Ops Tools — live state, signals, checks, and execution:
|
||||||
get_health_summary() — fleet health counts (healthy/degraded/down/unknown)
|
run(target, command, purpose, declared_risk) — general execution primitive; read-only auto-acts, mutations queue for approval, destructive always needs explicit confirmation
|
||||||
get_signal_history(entity_slug, state, limit) — open + recent signals
|
inspect_path(path, targets) — bulk mount/df/ls/stat across multiple hosts/LXCs
|
||||||
get_audit_trail(entity_id) — audit log filter + browse
|
get_execution_status(execution_id) — poll execution progress
|
||||||
get_agent_activity(limit) — agent self-inspection
|
tail_log(service_slug, lines=50) — journalctl for a service
|
||||||
query_metrics(hours=24) — time-series metric bucketed averages
|
get_service_status(service_slug) — systemctl is-active/is-enabled
|
||||||
get_trend(entity_id, days=7) — metric slope over time
|
get_lxc_state(lxc_slug) — pct status from Proxmox host
|
||||||
get_event_timeline(severity, entity_slug, limit) — recent events
|
ping_service(service_slug) — HTTP reachability + scheduler health state
|
||||||
|
list_lxcs(state) — all LXC containers with ID, host, IP, last-audited hint
|
||||||
|
restart_service(target, service) — restart a systemd service (config_mutation, requires approval)
|
||||||
|
docker_exec(lxc_slug, container, command, purpose) — run a command inside a Docker container on an LXC; handles escaping, resolves target from entity graph; read-only auto-runs, mutations require approval
|
||||||
|
push_file(target, source_path, dest_path, backup=true) — push a file into an LXC from the Proxmox host (config_mutation, requires approval)
|
||||||
|
ack_signal(signal_id) — acknowledge an open signal
|
||||||
|
resolve_signal(signal_id, resolution) — resolve a signal with optional note
|
||||||
|
mute_signal(signal_id, duration_s=3600) — temporarily mute a signal
|
||||||
|
cancel_execution(execution_id, reason) — cancel a queued/running execution
|
||||||
|
update_check(check_id, enabled) — enable or disable a health check
|
||||||
|
list_checks(entity_slug, enabled) — list health checks with verdict, probe kind
|
||||||
|
list_executions(entity_slug, status, limit=25) — cursor-paginated execution history
|
||||||
|
list_entity_sessions(entity_slug) — active Nomos sessions linked to an entity
|
||||||
|
get_dashboard_summary() — fleet overview: counts, health, signals, approvals
|
||||||
|
list_approvals(status, entity_slug, limit) — list pending/recent approvals; filter by status (pending, approved, denied) or entity
|
||||||
|
decide_approval(approval_id, decision) — approve or deny a pending execution; calls the same API endpoint as the Approve button in the UI
|
||||||
|
get_secret(key, path, environment) — retrieve a secret from the Infisical vault
|
||||||
|
list_secrets(path_prefix) — list secret keys in the Infisical vault
|
||||||
|
set_secret(key, value, path, environment) — store/update a secret (requires approval)
|
||||||
|
|
||||||
Knowledge — keep the graph current (none require approval; this updates
|
Knowledge Tools — search, read, and maintain the knowledge base:
|
||||||
the knowledge graph, not live infrastructure):
|
search_knowledge(query) — full-text search across docs (snippets, not full body)
|
||||||
upsert_knowledge(title, content) — record what you learned after solving
|
get_entity_knowledge(entity_slug) — all docs/investigations/runbooks linked to a slug
|
||||||
a non-obvious problem; the only way anything persists past a session
|
get_knowledge_content(slug) — full markdown body of one knowledge entry
|
||||||
update_entity_attributes(slug, attributes) — merge a discovered fact
|
upsert_knowledge(title, content, about, tags, kind) — write what you learned
|
||||||
(IP, version, port, ...) into an entity so a future task doesn't
|
upsert_session_summary(session_id, summary, entities_touched, discoveries) — batch-write session findings into the graph; creates knowledge entries, links entities, records a session-audit entry
|
||||||
rediscover it from scratch
|
delete_knowledge(knowledge_slug) — soft-delete a knowledge entry
|
||||||
create_relationship(source, target, type) — record a discovered edge
|
restore_knowledge(knowledge_slug) — restore a soft-deleted entry
|
||||||
(depends-on, hosts, routes-to, ...) between two entities
|
merge_knowledge(target_slug, source_slugs) — fold entries into a target
|
||||||
|
rename_knowledge_tag(from, to) — bulk-rename tags across all entries
|
||||||
|
get_knowledge_revisions(knowledge_slug) — version history for a knowledge entry
|
||||||
|
get_knowledge_duplicates(threshold=0.6) — near-duplicate detection via trigram similarity
|
||||||
|
get_knowledge_orphans(stale_days=90) — unlinked, untagged, or stale entries
|
||||||
|
list_knowledge_tags() — all tags with usage counts and casing variants
|
||||||
|
list_my_secrets(caller_pubkey) — secrets accessible to a client by age public key
|
||||||
|
|
||||||
Execution — mutating the live infrastructure:
|
Analysis Tools — fleet health, metrics, and introspection:
|
||||||
run(target, command) — the general execution primitive. Run any shell
|
get_health_summary(health) — fleet health per entity, optionally filtered
|
||||||
command against a host or LXC; every command is auto-classified —
|
get_audit_trail(entity_id) — query the audit log
|
||||||
read-only inspection runs immediately, anything state-changing needs
|
query_metrics(hours=24) — time-series with bucketed avg/min/max
|
||||||
operator approval, and destructive patterns (rm -rf, dd, mkfs,
|
get_signal_history(entity_slug, state, limit=50) — open and recent signals
|
||||||
pct/qm destroy, DROP TABLE, reboot, curl-pipe-to-shell, ...) always
|
get_patterns(status, entity_type, action) — learned action patterns
|
||||||
need approval regardless of what you declare. This is the ONLY
|
get_skills(status) — available automation skills
|
||||||
mutation tool — `request_execution` was retired 2026-07-14; the
|
get_trend(entity_id, days=7) — metric slope, variance, and averages
|
||||||
former enum actions (restart, systemctl, pct_exec, apt_upgrade,
|
get_event_timeline(severity, entity_slug, limit=50) — recent events
|
||||||
pct_create) are all expressed as `run(target, command)` now.
|
get_agent_activity(limit=50) — agent self-inspection log
|
||||||
get_execution_status(execution_id) — poll progress
|
classify_command(command, declared_risk) — pre-flight risk classification before `run`
|
||||||
|
get_ontology() — entity types, relationship types, and lifecycle definitions
|
||||||
|
http_get(url) — fetch a public web page/raw file; ~16KB cap
|
||||||
|
|
||||||
**When to prefer MCP over grepping the clone:** always for knowledge queries.
|
**When to prefer MCP over grepping the clone:** always for knowledge queries.
|
||||||
`search_knowledge("jellyfin hardware acceleration")` returns ranked results from
|
`search_knowledge("jellyfin hardware acceleration")` returns ranked results from
|
||||||
@@ -133,7 +154,9 @@ API/MCP bearer token — there is one shared
|
|||||||
secret (`OIKOS_MCP_BEARER_TOKEN`, validated in `internal/httpapi/server.go`'s
|
secret (`OIKOS_MCP_BEARER_TOKEN`, validated in `internal/httpapi/server.go`'s
|
||||||
`combinedAuth`); get it from the operator until per-client token issuance
|
`combinedAuth`); get it from the operator until per-client token issuance
|
||||||
exists. The SPA has its own flow instead: a first-launch Config screen that
|
exists. The SPA has its own flow instead: a first-launch Config screen that
|
||||||
stores a token in `localStorage` (see `web/src/pages/Config.svelte`).
|
stores a token in `localStorage` (the SPA lives in the `dtoro/oikos-web`
|
||||||
|
repo — `web/src/pages/Config.svelte` there — since the Phase 1 client
|
||||||
|
extraction).
|
||||||
|
|
||||||
## 5. Knowledge conventions
|
## 5. Knowledge conventions
|
||||||
|
|
||||||
@@ -168,13 +191,14 @@ The DB is the truth. The old wiki files are archived at `archive/knowledge/`
|
|||||||
- **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
|
||||||
operator approval via Matrix or the control-room UI's Operations page.
|
operator approval via the App button in the control-room UI or via
|
||||||
|
`list_approvals`/`decide_approval` MCP tools from any agent.
|
||||||
- **Secrets**: managed by Infisical (`oikos secret` subcommand for
|
- **Secrets**: managed by Infisical (`oikos secret` subcommand for
|
||||||
migration). Never hardcode secrets — use env vars from `.env`.
|
migration). Never hardcode secrets — use env vars from `.env`.
|
||||||
- **Mutations** (restart, edit configs, etc.): classified against
|
- **Mutations** (restart, edit configs, etc.): classified against
|
||||||
`seeds/policy.yaml`. `reversible_low` actions auto-execute;
|
`seeds/policy.yaml`. `reversible_low` actions auto-execute;
|
||||||
`config_mutation`/`destructive` actions require approval — granted by
|
`config_mutation`/`destructive` actions require approval — granted by
|
||||||
the operator via Matrix reply or the control-room UI, not a CLI flag.
|
the operator via the App button or `decide_approval` MCP tool call.
|
||||||
See OIKOS.md.
|
See OIKOS.md.
|
||||||
|
|
||||||
## 7. Communication mode
|
## 7. Communication mode
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Docker stack on mac-mini and exposes an MCP server + REST API.
|
|||||||
| Change history | MCP `get_change_history` |
|
| Change history | MCP `get_change_history` |
|
||||||
| State snapshot (health, disk, drift) | MCP `get_state_snapshot` |
|
| State snapshot (health, disk, drift) | MCP `get_state_snapshot` |
|
||||||
| Secrets (Infisical) | REST API + `oikos secret` CLI |
|
| Secrets (Infisical) | REST API + `oikos secret` CLI |
|
||||||
| Approval tokens | Matrix via notifier |
|
| Approve/deny pending executions | MCP `list_approvals`, `decide_approval` (chat-native, works on any platform) |
|
||||||
| Run a command on a host/LXC (policy-gated) | MCP `run` |
|
| Run a command on a host/LXC (policy-gated) | MCP `run` |
|
||||||
| Record a discovered fact/relationship | MCP `update_entity_attributes`, `create_relationship`, `upsert_knowledge` |
|
| Record a discovered fact/relationship | MCP `update_entity_attributes`, `create_relationship`, `upsert_knowledge` |
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ repo, see [.agents/dev/CONTRIBUTING.md](.agents/dev/CONTRIBUTING.md).
|
|||||||
- **Go 1.26+** (see `go.mod` for pinned version)
|
- **Go 1.26+** (see `go.mod` for pinned version)
|
||||||
- **PostgreSQL with TimescaleDB** — the compose stack includes `timescale/timescaledb:2.17.2-pg16`
|
- **PostgreSQL with TimescaleDB** — the compose stack includes `timescale/timescaledb:2.17.2-pg16`
|
||||||
- **Docker** for the full dev stack
|
- **Docker** for the full dev stack
|
||||||
- **Node 22+** for `web/` (the control-room SPA — standalone, not part of the
|
- The control-room SPA and desktop app live in their own repo —
|
||||||
compose stack or the `oikos` binary)
|
[dtoro/oikos-web](https://git.hubris.network/dtoro/oikos-web) (Node 22+
|
||||||
|
there); this repo is backend-only since the hexagonal refactor Phase 1
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start dependencies (Postgres + Redis). api/nomos require a shared bearer
|
# Start dependencies (Postgres + Redis). api/nomos require a shared bearer
|
||||||
@@ -26,62 +27,49 @@ make test-db
|
|||||||
# Build the binary
|
# Build the binary
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# SPA dev server (proxies to api/nomos, injecting the same token)
|
# SPA dev server (own repo — dtoro/oikos-web)
|
||||||
cd web && OIKOS_API_TOKEN=dev-token npm run dev
|
cd ~/Projects/oikos-web/web && OIKOS_API_TOKEN=dev-token npm run dev
|
||||||
|
|
||||||
# Desktop app (macOS)
|
# Desktop app (macOS — also in the oikos-web repo)
|
||||||
make desktop # build .app bundle
|
cd ~/Projects/oikos-web && make install
|
||||||
make install # build + install to /Applications
|
|
||||||
./cmd/desktop/build/bin/oikos-desktop.app/Contents/MacOS/oikos-desktop # run from terminal to see logs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Desktop app auth
|
### Desktop app
|
||||||
|
|
||||||
The desktop app uses the same API as the browser SPA. First launch:
|
The desktop app's auth (Authentik login → keychain-persisted token) and
|
||||||
|
auto-update (Gitea releases every 6 hours) moved with it to
|
||||||
1. Enter `https://oikos.hubris.network` as Server URL
|
[dtoro/oikos-web](https://git.hubris.network/dtoro/oikos-web) — see that
|
||||||
2. **Login with Authentik** tab → opens system browser → authenticate
|
repo's README. Auto-update now tracks oikos-web releases; builds installed
|
||||||
3. Callback page shows token → copy → paste into Token tab → Connect
|
before the split need one manual reinstall.
|
||||||
4. Token is persisted to the macOS keychain — subsequent launches skip setup
|
|
||||||
|
|
||||||
The app stores credentials via `github.com/zalando/go-keyring` (service: `com.hubris.oikos-desktop`).
|
|
||||||
|
|
||||||
### Desktop app auto-update
|
|
||||||
|
|
||||||
- Checks Gitea releases every 6 hours
|
|
||||||
- System tray → **Check for Updates** triggers an immediate check
|
|
||||||
- Download, extract, replace the app in `/Applications`, and relaunch
|
|
||||||
- Versions are compared against the `version` 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
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/desktop/ Wails v3 desktop app (macOS + Linux)
|
|
||||||
main.go Thin shell: webview, system tray, notifications, auto-update
|
|
||||||
wails.json Wails project config
|
|
||||||
entitlements.plist macOS code-signing entitlements
|
|
||||||
icon.png System tray icon (embedded)
|
|
||||||
icon.icns App bundle icon (white logo on black rounded rect)
|
|
||||||
Taskfile.yml Wails v3 build tasks
|
|
||||||
Info.plist.template macOS bundle metadata
|
|
||||||
cmd/oikos/ Single-binary entry point
|
cmd/oikos/ Single-binary entry point
|
||||||
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/ All Go packages
|
internal/ All Go packages
|
||||||
|
core/ Hexagon core ([ADR 0016](docs/adr/0016-hexagonal-ports-adapters.md)):
|
||||||
|
domain/ (pure model, stdlib only), ports/ (driven-port
|
||||||
|
interfaces), app/ (use-case services) — populated by the
|
||||||
|
phased refactor (plans/2026-08-15-hexagonal-architecture.md);
|
||||||
|
core may not import adapters, enforced by depguard
|
||||||
|
adapters/ Ports' implementations (postgres, ssh, probes, remote,
|
||||||
|
secrets, events) + driving adapters (httpapi, mcpserver,
|
||||||
|
scheduler, execworker, cli) — scaffolded; packages move
|
||||||
|
here phase by phase
|
||||||
httpapi/ REST + MCP server (OpenAPI-generated)
|
httpapi/ REST + MCP server (OpenAPI-generated)
|
||||||
mcp/ MCP tool implementations
|
mcp/ MCP tool implementations
|
||||||
db/ Connection pool, migrations, seeds, sqlc queries
|
db/ (moved) → internal/adapters/postgres: pool, migrations,
|
||||||
|
seeds, sqlc queries — package still named `db` until
|
||||||
|
the Phase 3 repository split
|
||||||
scheduler/ Observe loop, probes, signals
|
scheduler/ Observe loop, probes, signals
|
||||||
actuator/ SSH execution
|
actuator/ SSH execution
|
||||||
learning/ Pattern recognition, anomaly detection
|
learning/ Pattern recognition, anomaly detection
|
||||||
notifier/ Matrix notifications, approval tokens
|
|
||||||
policy/ Risk classifier
|
policy/ Risk classifier
|
||||||
secrets/ Infisical + SOPS backend
|
secrets/ Infisical + SOPS backend
|
||||||
domain/ Core types: entities, approvals, signals, patterns
|
|
||||||
ontology/ Type hierarchy, relationship validation
|
ontology/ Type hierarchy, relationship validation
|
||||||
knowledge/ Knowledge YAML seed ingestion
|
knowledge/ Knowledge YAML seed ingestion
|
||||||
web/ Control-room SPA (Svelte 5) — standalone, not embedded
|
|
||||||
in the oikos binary; see plans/2026-07-12-wails-desktop-app.md
|
|
||||||
api/openapi.yaml API contract — the source of truth for endpoints
|
api/openapi.yaml API contract — the source of truth for endpoints
|
||||||
migrations/ Forward-only SQL migrations (TimescaleDB)
|
migrations/ Forward-only SQL migrations (TimescaleDB)
|
||||||
seeds/ Bootstrap YAML: ontology, inventory, policy, knowledge
|
seeds/ Bootstrap YAML: ontology, inventory, policy, knowledge
|
||||||
@@ -111,11 +99,6 @@ docs/operations/ Runbooks (rollback, etc.)
|
|||||||
| `make export` | Export DB state to YAML seeds |
|
| `make export` | Export DB state to YAML seeds |
|
||||||
| `make dev` | Start compose dev stack |
|
| `make dev` | Start compose dev stack |
|
||||||
| `make clean` | Remove binary + test cache |
|
| `make clean` | Remove binary + test cache |
|
||||||
| `make ui` | Build the SPA (`web/dist/`) |
|
|
||||||
| `make deploy-ui` | Build + deploy the SPA to the Caddy host |
|
|
||||||
| `make desktop` | Build the Wails desktop app for the current platform |
|
|
||||||
| `make desktop-package` | Build + package (zip on macOS, tar.gz on Linux) |
|
|
||||||
| `make install` | Build + install to `/Applications` (macOS) |
|
|
||||||
| `make webhook` | Build `cmd/webhook` (deploy-webhook receiver) |
|
| `make webhook` | Build `cmd/webhook` (deploy-webhook receiver) |
|
||||||
| `make tidy` | `go mod tidy` |
|
| `make tidy` | `go mod tidy` |
|
||||||
|
|
||||||
@@ -135,8 +118,8 @@ Never hand-edit `internal/httpapi/gen/api.gen.go`.
|
|||||||
|
|
||||||
### Database access is sqlc-first
|
### Database access is sqlc-first
|
||||||
|
|
||||||
SQL queries live in `internal/db/queries/*.sql`. Go code is generated with
|
SQL queries live in `internal/adapters/postgres/queries/*.sql`. Go code is generated with
|
||||||
`sqlc` into `internal/db/sqlcgen/`. Config in `sqlc.yaml`.
|
`sqlc` into `internal/adapters/postgres/sqlcgen/`. Config in `sqlc.yaml`.
|
||||||
|
|
||||||
- Queries target pgx/v5 with UUID + timestamptz overrides
|
- Queries target pgx/v5 with UUID + timestamptz overrides
|
||||||
- Never hand-edit generated sqlc code
|
- Never hand-edit generated sqlc code
|
||||||
|
|||||||
42
Makefile
42
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.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
|
||||||
|
|
||||||
BINARY := bin/oikos
|
BINARY := bin/oikos
|
||||||
GO ?= go
|
GO ?= go
|
||||||
@@ -18,7 +18,7 @@ test-db:
|
|||||||
docker compose up -d postgres
|
docker compose up -d postgres
|
||||||
@sleep 3
|
@sleep 3
|
||||||
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/adapters/postgres/ ./internal/httpapi/ ./internal/mcp/
|
||||||
|
|
||||||
lint: vet golangci govulncheck
|
lint: vet golangci govulncheck
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ generate:
|
|||||||
|
|
||||||
# CI drift guard: regenerate and fail if the committed output changed.
|
# CI drift guard: regenerate and fail if the committed output changed.
|
||||||
generate-check: generate
|
generate-check: generate
|
||||||
@git diff --exit-code -- internal/httpapi/gen internal/db/sqlcgen \
|
@git diff --exit-code -- internal/httpapi/gen internal/adapters/postgres/sqlcgen \
|
||||||
|| (echo "generated code is stale — run 'make generate' and commit" && exit 1)
|
|| (echo "generated code is stale — run 'make generate' and commit" && exit 1)
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
@@ -55,45 +55,9 @@ export:
|
|||||||
dev:
|
dev:
|
||||||
docker compose --profile dev up -d
|
docker compose --profile dev up -d
|
||||||
|
|
||||||
# Local sanity-check build of the SPA. Not embedded in the oikos binary
|
|
||||||
# (plans/2026-07-12-wails-desktop-app.md 0.1) — deploys as its own
|
|
||||||
# container (compose/web/Dockerfile) via `docker compose --profile full
|
|
||||||
# up -d web`, same push-to-main pipeline as everything else.
|
|
||||||
ui:
|
|
||||||
cd web && npm run build
|
|
||||||
|
|
||||||
desktop: ui ## Build the Wails desktop app for the current platform
|
|
||||||
rm -rf cmd/desktop/frontend/dist
|
|
||||||
mkdir -p cmd/desktop/frontend/dist
|
|
||||||
cp -r web/dist/* cmd/desktop/frontend/dist/
|
|
||||||
cd cmd/desktop && CGO_ENABLED=1 go build -ldflags "-X main.version=$$(cat ../VERSION)" -o build/bin/Oikos .
|
|
||||||
|
|
||||||
desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux)
|
|
||||||
@case $$(uname -s) in \
|
|
||||||
Darwin) \
|
|
||||||
APP="cmd/desktop/build/bin/Oikos.app"; \
|
|
||||||
rm -rf "$$APP"; \
|
|
||||||
mkdir -p "$$APP/Contents/MacOS"; \
|
|
||||||
mkdir -p "$$APP/Contents/Resources"; \
|
|
||||||
cp cmd/desktop/build/bin/Oikos "$$APP/Contents/MacOS/Oikos"; \
|
|
||||||
cp cmd/desktop/icon.icns "$$APP/Contents/Resources/icon.icns"; \
|
|
||||||
sed "s/\$$(VERSION)/$$(cat VERSION)/" cmd/desktop/Info.plist.template > "$$APP/Contents/Info.plist"; \
|
|
||||||
cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip Oikos.app ;; \
|
|
||||||
Linux) \
|
|
||||||
cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz Oikos ;; \
|
|
||||||
esac
|
|
||||||
@echo "Package: cmd/desktop/build/bin/"
|
|
||||||
|
|
||||||
install: desktop-package ## Install to /Applications
|
|
||||||
rm -rf /Applications/Oikos.app
|
|
||||||
cp -r cmd/desktop/build/bin/Oikos.app /Applications/
|
|
||||||
@echo "Installed to /Applications/Oikos.app"
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BINARY)
|
rm -f $(BINARY)
|
||||||
rm -rf bin
|
rm -rf bin
|
||||||
rm -rf cmd/desktop/build
|
|
||||||
rm -rf cmd/desktop/frontend/dist
|
|
||||||
$(GO) clean -testcache
|
$(GO) clean -testcache
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -13,7 +13,7 @@ learns from outcomes, and escalates when uncertain.
|
|||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Dev stack (postgres + api + scheduler + notifier). The api/nomos
|
# Dev stack (postgres + api + scheduler). The api/nomos
|
||||||
# services need a shared token — every route requires a real bearer
|
# services need a shared token — every route requires a real bearer
|
||||||
# credential, there's no dev-open bypass.
|
# credential, there's no dev-open bypass.
|
||||||
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d
|
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d
|
||||||
@@ -29,8 +29,8 @@ OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disa
|
|||||||
OIKOS_API_TOKEN=dev-token \
|
OIKOS_API_TOKEN=dev-token \
|
||||||
go run ./cmd/oikos all
|
go run ./cmd/oikos all
|
||||||
|
|
||||||
# Control-room SPA (separate from the Go binary — see web/)
|
# Control-room SPA + desktop app: own repo — dtoro/oikos-web
|
||||||
cd web && OIKOS_API_TOKEN=dev-token npm run dev # http://localhost:5173
|
# (~/Projects/oikos-web; cd web && OIKOS_API_TOKEN=dev-token npm run dev)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -42,8 +42,8 @@ cd web && OIKOS_API_TOKEN=dev-token npm run dev # http://localhost:5173
|
|||||||
Workstation ─── │ nomos (8092) ──MCP── api (8090) │
|
Workstation ─── │ nomos (8092) ──MCP── api (8090) │
|
||||||
(mesh) │ MCP gateway REST + MCP │
|
(mesh) │ MCP gateway REST + MCP │
|
||||||
│ │
|
│ │
|
||||||
│ scheduler ── notifier ── postgres │
|
│ scheduler ─── postgres │
|
||||||
│ (observe) (Matrix) (Timescale)│
|
│ (observe) (Timescale) │
|
||||||
└──────────────────────────────────┘
|
└──────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,7 +51,6 @@ cd web && OIKOS_API_TOKEN=dev-token npm run dev # http://localhost:5173
|
|||||||
|-----------|------|------|
|
|-----------|------|------|
|
||||||
| `oikos api` | 8090 | REST API + MCP server (tool list in [AGENTS.md §3](AGENTS.md#3-the-mcp-server)) |
|
| `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 |
|
|
||||||
| `nomos serve` | 8092 | MCP client gateway, query routing |
|
| `nomos serve` | 8092 | MCP client gateway, query routing |
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
@@ -60,7 +59,7 @@ cd web && OIKOS_API_TOKEN=dev-token npm run dev # http://localhost:5173
|
|||||||
|-------|--------|-------------|
|
|-------|--------|-------------|
|
||||||
| 1 — Ontology + DB | ✅ | TimescaleDB, migrations, seeds, blast_radius |
|
| 1 — Ontology + DB | ✅ | TimescaleDB, migrations, seeds, blast_radius |
|
||||||
| 2 — API | ✅ | OpenAPI-first REST + MCP, auth, SSE, audit |
|
| 2 — API | ✅ | OpenAPI-first REST + MCP, auth, SSE, audit |
|
||||||
| 3 — Control loop | ✅ | Scheduler, actuator, learning, classifier, notifier |
|
| 3 — Control loop | ✅ | Scheduler, actuator, learning, classifier |
|
||||||
| 4 — Nomos agent | ✅ | Standalone MCP client gateway, agent activity |
|
| 4 — Nomos agent | ✅ | Standalone MCP client gateway, agent activity |
|
||||||
| 5 — Secrets | ✅ | Infisical backend + SOPS fallback, rotation runbooks |
|
| 5 — Secrets | ✅ | Infisical backend + SOPS fallback, rotation runbooks |
|
||||||
| 6 — Deploy | ✅ | CI pipeline, cutover checklist, watchdog, rollback |
|
| 6 — Deploy | ✅ | CI pipeline, cutover checklist, watchdog, rollback |
|
||||||
@@ -100,7 +99,6 @@ oikos seed # ingest ontology/inventory/policy seeds
|
|||||||
oikos export # export DB state to YAML
|
oikos export # export DB state to YAML
|
||||||
oikos api # serve REST + MCP
|
oikos api # serve REST + MCP
|
||||||
oikos scheduler # run observe loop
|
oikos scheduler # run observe loop
|
||||||
oikos notifier # run notification loop
|
|
||||||
oikos all # all roles in one process
|
oikos all # all roles in one process
|
||||||
oikos secret list # enumerate SOPS secrets
|
oikos secret list # enumerate SOPS secrets
|
||||||
oikos secret migrate # SOPS → Infisical
|
oikos secret migrate # SOPS → Infisical
|
||||||
@@ -108,12 +106,14 @@ oikos secret migrate # SOPS → Infisical
|
|||||||
|
|
||||||
### Web UI
|
### Web UI
|
||||||
|
|
||||||
`web/` is a standalone Svelte 5 SPA — not embedded in the `oikos` binary, not
|
The control-room SPA and the Wails desktop wrapper live in their own repo,
|
||||||
part of `docker-compose.yml`. It talks to `api`/`nomos` over HTTP with a
|
[dtoro/oikos-web](https://git.hubris.network/dtoro/oikos-web) (local
|
||||||
bearer token entered on first launch (see `web/src/pages/Config.svelte`).
|
checkout `~/Projects/oikos-web`) — extracted in Phase 1 of
|
||||||
Build with `make ui`, deploy with `make deploy-ui` (Caddy serves the static
|
[plans/2026-08-15-hexagonal-architecture.md](plans/2026-08-15-hexagonal-architecture.md).
|
||||||
output). A native desktop wrapper exists at `cmd/desktop/` — see
|
The SPA talks to `api`/`nomos` over HTTP with a bearer token entered on
|
||||||
[plans/done/2026-07-12-wails-desktop-app.md](plans/done/2026-07-12-wails-desktop-app.md).
|
first launch. It deploys as its own compose project publishing `8091:80`;
|
||||||
|
the outer Caddy (LXC 121) targets that published port, so serving and auth
|
||||||
|
are unchanged from the pre-split stack.
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
@@ -121,11 +121,9 @@ output). A native desktop wrapper exists at `cmd/desktop/` — 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)
|
||||||
cmd/desktop/ Wails desktop wrapper around the SPA
|
internal/ Go packages (actuator, checkdefaults, config, core, db,
|
||||||
internal/ Go packages (actuator, checkdefaults, config, db, domain,
|
domain, httpapi, knowledge, learning, mcp, observability,
|
||||||
httpapi, knowledge, learning, mcp, notifier, observability,
|
|
||||||
ontology, policy, safego, scheduler, secrets)
|
ontology, policy, safego, scheduler, secrets)
|
||||||
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)
|
||||||
seeds/ Bootstrap YAML (ontology, inventory, policy, knowledge)
|
seeds/ Bootstrap YAML (ontology, inventory, policy, knowledge)
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -3168,8 +3185,6 @@ components:
|
|||||||
required:
|
required:
|
||||||
- age_public_key
|
- age_public_key
|
||||||
- age_private_key
|
- age_private_key
|
||||||
- infisical_client_id
|
|
||||||
- infisical_client_secret
|
|
||||||
properties:
|
properties:
|
||||||
age_public_key:
|
age_public_key:
|
||||||
type: string
|
type: string
|
||||||
@@ -3183,9 +3198,6 @@ components:
|
|||||||
infisical_client_secret:
|
infisical_client_secret:
|
||||||
type: string
|
type: string
|
||||||
description: Infisical UniversalAuth client secret
|
description: Infisical UniversalAuth client secret
|
||||||
machine_identity_token:
|
|
||||||
type: string
|
|
||||||
description: Infisical machine identity access token
|
|
||||||
ClientContext:
|
ClientContext:
|
||||||
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)\"}"
|
||||||
|
|||||||
12
checks/pvecm_quorum_check.sh
Normal file
12
checks/pvecm_quorum_check.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pvecm_quorum_check.sh — Proxmox cluster quorum status.
|
||||||
|
# Runs on a PVE host. Fails if the node is not quorate.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# pvecm status exit code is non-zero on non-quorate nodes
|
||||||
|
# (e.g. "Quorate: No — Activity blocked")
|
||||||
|
if pvecm status 2>/dev/null | grep -q 'Quorate.*Yes'; then
|
||||||
|
echo '{"health":"healthy","metrics":{"quorate":1}}'
|
||||||
|
else
|
||||||
|
echo '{"health":"unhealthy","metrics":{"quorate":0}}'
|
||||||
|
fi
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
|
||||||
<string>en</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>Oikos</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>com.hubris.oikos-desktop</string>
|
|
||||||
<key>CFBundleIconFile</key>
|
|
||||||
<string>icon</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>Oikos</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>APPL</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>$(VERSION)</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>$(VERSION)</string>
|
|
||||||
<key>LSMinimumSystemVersion</key>
|
|
||||||
<string>13.0</string>
|
|
||||||
<key>NSHighResolutionCapable</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSHumanReadableCopyright</key>
|
|
||||||
<string>Copyright © 2026 Hubris. All rights reserved.</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
build:
|
|
||||||
summary: Build the Oikos desktop app
|
|
||||||
cmds:
|
|
||||||
- go build -o build/bin/Oikos .
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 1
|
|
||||||
|
|
||||||
dev:
|
|
||||||
summary: Run in development mode
|
|
||||||
cmds:
|
|
||||||
- go run .
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.app-sandbox</key>
|
|
||||||
<false/>
|
|
||||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.device.audio-input</key>
|
|
||||||
<false/>
|
|
||||||
<key>com.apple.security.device.camera</key>
|
|
||||||
<false/>
|
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.client</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.server</key>
|
|
||||||
<false/>
|
|
||||||
<key>keychain-access-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>$(AppIdentifierPrefix)com.hubris.oikos-desktop</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,788 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"embed"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"os/user"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/events"
|
|
||||||
"github.com/zalando/go-keyring"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed frontend/dist
|
|
||||||
var assets embed.FS
|
|
||||||
|
|
||||||
//go:embed icon.png
|
|
||||||
var iconPNG []byte
|
|
||||||
|
|
||||||
const (
|
|
||||||
keyringService = "com.hubris.oikos-desktop"
|
|
||||||
keyringUser = "oikos"
|
|
||||||
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
|
|
||||||
pollInterval = 30 * time.Second
|
|
||||||
updateInterval = 6 * time.Hour
|
|
||||||
oidcCallbackPort = 18901
|
|
||||||
)
|
|
||||||
|
|
||||||
// version is injected at link time via -ldflags "-X main.version=$(cat VERSION)"
|
|
||||||
// (Makefile desktop target). The default keeps a non-empty fallback for
|
|
||||||
// `go build ./cmd/desktop` without ldflags.
|
|
||||||
var version = "0.1.0-dev"
|
|
||||||
|
|
||||||
type OikosConfig struct {
|
|
||||||
ApiUrl string `json:"apiUrl"`
|
|
||||||
Token string `json:"token,omitempty"`
|
|
||||||
IsDesktop bool `json:"isDesktop"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- ConfigService ----
|
|
||||||
|
|
||||||
type ConfigService struct{}
|
|
||||||
|
|
||||||
func (c *ConfigService) Name() string { return "config" }
|
|
||||||
|
|
||||||
func (c *ConfigService) SaveConfig(apiUrl, token string) error {
|
|
||||||
cfg := OikosConfig{ApiUrl: apiUrl, Token: token, IsDesktop: true}
|
|
||||||
data, _ := json.Marshal(cfg)
|
|
||||||
return keyring.Set(keyringService, keyringUser, string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConfigService) ClearConfig() error {
|
|
||||||
return keyring.Delete(keyringService, keyringUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConfigService) GetStoredConfig() *OikosConfig {
|
|
||||||
return loadConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConfigService) EnableAutoStart() error {
|
|
||||||
if runtime.GOOS != "darwin" {
|
|
||||||
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
|
|
||||||
}
|
|
||||||
usr, _ := user.Current()
|
|
||||||
dir := filepath.Join(usr.HomeDir, "Library", "LaunchAgents")
|
|
||||||
os.MkdirAll(dir, 0755)
|
|
||||||
|
|
||||||
exe, _ := os.Executable()
|
|
||||||
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>com.hubris.oikos-desktop</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>%s</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
</plist>`, exe)
|
|
||||||
|
|
||||||
return os.WriteFile(filepath.Join(dir, "com.hubris.oikos-desktop.plist"), []byte(plist), 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ConfigService) DisableAutoStart() error {
|
|
||||||
if runtime.GOOS != "darwin" {
|
|
||||||
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
|
|
||||||
}
|
|
||||||
usr, _ := user.Current()
|
|
||||||
path := filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.hubris.oikos-desktop.plist")
|
|
||||||
return os.Remove(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Local OIDC server (runs alongside the webview) ----
|
|
||||||
|
|
||||||
type oidcSession struct {
|
|
||||||
apiUrl string
|
|
||||||
verifier string
|
|
||||||
state string
|
|
||||||
ch chan string
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
oidcSessionsMu sync.Mutex
|
|
||||||
oidcSessions = make(map[string]*oidcSession)
|
|
||||||
)
|
|
||||||
|
|
||||||
func startOIDCServer() *http.Server {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
cors := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
if r.Method == "OPTIONS" {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h := func(path string, handler func(http.ResponseWriter, *http.Request)) {
|
|
||||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cors(w, r)
|
|
||||||
if r.Method == "OPTIONS" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handler(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
h("/oidc/start", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
|
|
||||||
returnURL := r.URL.Query().Get("ret")
|
|
||||||
if apiUrl == "" {
|
|
||||||
http.Error(w, "apiUrl required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if returnURL == "" {
|
|
||||||
returnURL = "/?desktop=1"
|
|
||||||
}
|
|
||||||
|
|
||||||
oidcCfg, err := fetchOIDCConfig(apiUrl)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
verifier, challenge, _ := pkceParams()
|
|
||||||
state := randomString(32)
|
|
||||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort)
|
|
||||||
|
|
||||||
ch := make(chan string, 1)
|
|
||||||
oidcSessionsMu.Lock()
|
|
||||||
sessionID := randomString(16)
|
|
||||||
oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
|
|
||||||
oidcSessionsMu.Unlock()
|
|
||||||
|
|
||||||
authURL := fmt.Sprintf("%s?%s",
|
|
||||||
oidcCfg.AuthorizationEndpoint,
|
|
||||||
url.Values{
|
|
||||||
"response_type": {"code"},
|
|
||||||
"client_id": {oidcCfg.ClientID},
|
|
||||||
"redirect_uri": {redirectURI},
|
|
||||||
"code_challenge": {challenge},
|
|
||||||
"code_challenge_method": {"S256"},
|
|
||||||
"state": {state},
|
|
||||||
"scope": {"openid profile email"},
|
|
||||||
}.Encode(),
|
|
||||||
)
|
|
||||||
|
|
||||||
exec.Command("open", authURL).Start()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case token := <-ch:
|
|
||||||
if token != "" {
|
|
||||||
c := &ConfigService{}
|
|
||||||
c.SaveConfig(apiUrl, token)
|
|
||||||
returnURL += "&token=" + url.QueryEscape(token)
|
|
||||||
}
|
|
||||||
case <-time.After(5 * time.Minute):
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
|
||||||
<meta http-equiv="refresh" content="0;url=%s">
|
|
||||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
||||||
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
|
||||||
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
|
||||||
</head><body><div class="card"><h1>Connected</h1><p class="ok">Redirecting back to Oikos…</p></div></body></html>`, returnURL)
|
|
||||||
})
|
|
||||||
|
|
||||||
h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
gotState := r.URL.Query().Get("state")
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
|
|
||||||
oidcSessionsMu.Lock()
|
|
||||||
var session *oidcSession
|
|
||||||
var sessionID string
|
|
||||||
for id, s := range oidcSessions {
|
|
||||||
if s.state == gotState {
|
|
||||||
session = s
|
|
||||||
sessionID = id
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
oidcSessionsMu.Unlock()
|
|
||||||
|
|
||||||
if session == nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("Invalid state."))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := exchangeCode(
|
|
||||||
session.apiUrl,
|
|
||||||
code, session.verifier,
|
|
||||||
fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort),
|
|
||||||
)
|
|
||||||
|
|
||||||
oidcSessionsMu.Lock()
|
|
||||||
delete(oidcSessions, sessionID)
|
|
||||||
oidcSessionsMu.Unlock()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
fmt.Fprintf(w, "Token exchange failed: %v", err)
|
|
||||||
session.ch <- ""
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
|
||||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
||||||
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
|
||||||
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
|
||||||
</head><body><div class="card"><h1>Connected</h1><p class="ok">You can close this window and return to Oikos.</p></div></body></html>`))
|
|
||||||
session.ch <- token
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/oidc/config", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
|
|
||||||
if apiUrl == "" {
|
|
||||||
http.Error(w, "apiUrl required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg, err := fetchOIDCConfig(apiUrl)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(cfg)
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/update/check", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
latest := fetchLatestRelease()
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
if latest == nil {
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"current": version})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hasAsset := false
|
|
||||||
for _, a := range latest.Assets {
|
|
||||||
if strings.Contains(a.Name, "darwin") {
|
|
||||||
hasAsset = true
|
|
||||||
updater.mu.Lock()
|
|
||||||
updater.latestURL = a.BrowserDownloadURL
|
|
||||||
updater.mu.Unlock()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"current": version,
|
|
||||||
"latest": latest.Version,
|
|
||||||
"has_asset": fmt.Sprintf("%t", hasAsset),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort))
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("OIDC server: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Printf("OIDC server listening on %s", listener.Addr())
|
|
||||||
srv := &http.Server{Handler: mux}
|
|
||||||
go srv.Serve(listener)
|
|
||||||
return srv
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Window persistence ----
|
|
||||||
|
|
||||||
type windowState struct {
|
|
||||||
X int `json:"x"`
|
|
||||||
Y int `json:"y"`
|
|
||||||
Width int `json:"width"`
|
|
||||||
Height int `json:"height"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func windowStatePath() string {
|
|
||||||
usr, _ := user.Current()
|
|
||||||
return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadWindowState() *windowState {
|
|
||||||
data, err := os.ReadFile(windowStatePath())
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var ws windowState
|
|
||||||
if err := json.Unmarshal(data, &ws); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if ws.Width < 200 || ws.Height < 200 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &ws
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveWindowState(w application.Window) {
|
|
||||||
x, y := w.Position()
|
|
||||||
width, height := w.Size()
|
|
||||||
ws := windowState{X: x, Y: y, Width: width, Height: height}
|
|
||||||
data, _ := json.Marshal(ws)
|
|
||||||
|
|
||||||
usr, _ := user.Current()
|
|
||||||
dir := filepath.Join(usr.HomeDir, ".config", "oikos")
|
|
||||||
os.MkdirAll(dir, 0755)
|
|
||||||
os.WriteFile(filepath.Join(dir, "window.json"), data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadConfig() *OikosConfig {
|
|
||||||
data, err := keyring.Get(keyringService, keyringUser)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var cfg OikosConfig
|
|
||||||
if err := json.Unmarshal([]byte(data), &cfg); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cfg.IsDesktop = true
|
|
||||||
return &cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
type oidcConfig struct {
|
|
||||||
Issuer string `json:"issuer"`
|
|
||||||
ClientID string `json:"client_id"`
|
|
||||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) {
|
|
||||||
resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
var cfg oidcConfig
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func pkceParams() (verifier, challenge string, _ error) {
|
|
||||||
v := randomString(64)
|
|
||||||
h := sha256.Sum256([]byte(v))
|
|
||||||
return v, base64.RawURLEncoding.EncodeToString(h[:]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomString(n int) string {
|
|
||||||
b := make([]byte, n)
|
|
||||||
rand.Read(b)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) {
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": code,
|
|
||||||
"code_verifier": verifier,
|
|
||||||
"redirect_uri": redirectURI,
|
|
||||||
})
|
|
||||||
|
|
||||||
resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body)))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
b, _ := io.ReadAll(resp.Body)
|
|
||||||
return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
var tokens struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if tokens.AccessToken == "" {
|
|
||||||
return "", fmt.Errorf("no access_token in response")
|
|
||||||
}
|
|
||||||
return tokens.AccessToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Notifications ----
|
|
||||||
|
|
||||||
type dashboardSummary struct {
|
|
||||||
ApprovalsPending int `json:"approvals_pending"`
|
|
||||||
Signals struct {
|
|
||||||
Critical int `json:"critical"`
|
|
||||||
} `json:"signals_by_severity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *dashboardSummary) alertCount() int {
|
|
||||||
return d.ApprovalsPending + d.Signals.Critical
|
|
||||||
}
|
|
||||||
|
|
||||||
func notify(title, subtitle string) {
|
|
||||||
if runtime.GOOS != "darwin" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
script := fmt.Sprintf(
|
|
||||||
`display notification "%s" with title "%s" sound name "default"`,
|
|
||||||
strings.ReplaceAll(subtitle, `"`, `\"`),
|
|
||||||
strings.ReplaceAll(title, `"`, `\"`),
|
|
||||||
)
|
|
||||||
exec.Command("osascript", "-e", script).Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func pollDashboard(cfg *OikosConfig) {
|
|
||||||
if cfg == nil || cfg.ApiUrl == "" || cfg.Token == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastCount int
|
|
||||||
first := true
|
|
||||||
|
|
||||||
for {
|
|
||||||
req, err := http.NewRequest("GET", cfg.ApiUrl+"/api/v1/dashboard/summary", nil)
|
|
||||||
if err != nil {
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
var summary dashboardSummary
|
|
||||||
if err := json.Unmarshal(body, &summary); err != nil {
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if first {
|
|
||||||
lastCount = summary.alertCount()
|
|
||||||
first = false
|
|
||||||
} else {
|
|
||||||
current := summary.alertCount()
|
|
||||||
if current > lastCount {
|
|
||||||
notify("Oikos", fmt.Sprintf("%d pending approval(s), %d critical signal(s)", summary.ApprovalsPending, summary.Signals.Critical))
|
|
||||||
}
|
|
||||||
lastCount = current
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Auto-update ----
|
|
||||||
|
|
||||||
type giteaRelease struct {
|
|
||||||
TagName string `json:"tag_name"`
|
|
||||||
Assets []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
BrowserDownloadURL string `json:"browser_download_url"`
|
|
||||||
} `json:"assets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateState struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
latestURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
var updater = &updateState{}
|
|
||||||
|
|
||||||
// CheckForUpdates checks Gitea releases for a newer version. If found, stores
|
|
||||||
// the download URL and returns the latest version string (empty if current).
|
|
||||||
func (c *ConfigService) CheckForUpdates() string {
|
|
||||||
latest := fetchLatestRelease()
|
|
||||||
if latest == nil || latest.Version == version {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, a := range latest.Assets {
|
|
||||||
if strings.Contains(a.Name, "darwin") {
|
|
||||||
updater.mu.Lock()
|
|
||||||
updater.latestURL = a.BrowserDownloadURL
|
|
||||||
updater.mu.Unlock()
|
|
||||||
return latest.Version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstallUpdate downloads the stored update, replaces the app, and restarts.
|
|
||||||
func (c *ConfigService) InstallUpdate() error {
|
|
||||||
updater.mu.Lock()
|
|
||||||
url := updater.latestURL
|
|
||||||
updater.mu.Unlock()
|
|
||||||
if url == "" {
|
|
||||||
return fmt.Errorf("no update available")
|
|
||||||
}
|
|
||||||
return doUpdate(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
type latestRelease struct {
|
|
||||||
Version string
|
|
||||||
Assets []struct {
|
|
||||||
Name string
|
|
||||||
BrowserDownloadURL string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchLatestRelease() *latestRelease {
|
|
||||||
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var releases []giteaRelease
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
r := releases[0]
|
|
||||||
v := strings.TrimPrefix(r.TagName, "v")
|
|
||||||
if v == version {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lr := &latestRelease{Version: v}
|
|
||||||
for _, a := range r.Assets {
|
|
||||||
lr.Assets = append(lr.Assets, struct {
|
|
||||||
Name string
|
|
||||||
BrowserDownloadURL string
|
|
||||||
}{a.Name, a.BrowserDownloadURL})
|
|
||||||
}
|
|
||||||
return lr
|
|
||||||
}
|
|
||||||
|
|
||||||
func doUpdate(downloadURL string) error {
|
|
||||||
tmp, err := os.CreateTemp("", "oikos-update-*.zip")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.Remove(tmp.Name())
|
|
||||||
|
|
||||||
resp, err := http.Get(downloadURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tmp.Close()
|
|
||||||
|
|
||||||
extractDir, err := os.MkdirTemp("", "oikos-extract")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(extractDir)
|
|
||||||
|
|
||||||
cmd := exec.Command("unzip", "-o", tmp.Name(), "-d", extractDir)
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("unzip: %w: %s", err, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
newApp := filepath.Join(extractDir, "Oikos.app")
|
|
||||||
if _, err := os.Stat(newApp); err != nil {
|
|
||||||
return fmt.Errorf("extracted app not found: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentApp := "/Applications/Oikos.app"
|
|
||||||
if _, err := os.Stat(currentApp); os.IsNotExist(err) {
|
|
||||||
if exe, err := os.Executable(); err == nil {
|
|
||||||
currentApp = filepath.Dir(filepath.Dir(filepath.Dir(exe)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
script := fmt.Sprintf(`#!/bin/bash
|
|
||||||
sleep 2
|
|
||||||
rm -rf "%s"
|
|
||||||
mv "%s" "%s"
|
|
||||||
open "%s"
|
|
||||||
rm "$0"
|
|
||||||
`, currentApp, newApp, currentApp, currentApp)
|
|
||||||
|
|
||||||
scriptPath := filepath.Join(os.TempDir(), "oikos-update.sh")
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
app := application.Get()
|
|
||||||
exec.Command("open", scriptPath).Start()
|
|
||||||
if app != nil {
|
|
||||||
app.Quit()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkUpdates() {
|
|
||||||
for {
|
|
||||||
time.Sleep(updateInterval)
|
|
||||||
|
|
||||||
latest := fetchLatestRelease()
|
|
||||||
if latest == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, a := range latest.Assets {
|
|
||||||
if strings.Contains(a.Name, "darwin") {
|
|
||||||
updater.mu.Lock()
|
|
||||||
updater.latestURL = a.BrowserDownloadURL
|
|
||||||
updater.mu.Unlock()
|
|
||||||
|
|
||||||
app := application.Get()
|
|
||||||
if app == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
msg := fmt.Sprintf("Version %s is available (you have %s).", latest.Version, version)
|
|
||||||
app.Dialog.Info().
|
|
||||||
SetTitle("Update Available").
|
|
||||||
SetMessage(msg).
|
|
||||||
Show()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Main ----
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
oidcSrv := startOIDCServer()
|
|
||||||
defer oidcSrv.Close()
|
|
||||||
|
|
||||||
distFS, err := fs.Sub(assets, "frontend/dist")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("embedded assets: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
app := application.New(application.Options{
|
|
||||||
Name: "Oikos",
|
|
||||||
Description: "Homelab Control Room",
|
|
||||||
Services: []application.Service{
|
|
||||||
application.NewService(&ConfigService{}),
|
|
||||||
},
|
|
||||||
Assets: application.AssetOptions{
|
|
||||||
Handler: application.AssetFileServerFS(distFS),
|
|
||||||
},
|
|
||||||
Mac: application.MacOptions{
|
|
||||||
ApplicationShouldTerminateAfterLastWindowClosed: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
systemTray := app.SystemTray.New()
|
|
||||||
systemTray.SetTooltip("Oikos")
|
|
||||||
systemTray.SetIcon(iconPNG)
|
|
||||||
|
|
||||||
trayMenu := application.NewMenu()
|
|
||||||
trayMenu.Add("Open Oikos").OnClick(func(ctx *application.Context) {
|
|
||||||
for _, w := range app.Window.GetAll() {
|
|
||||||
w.Show()
|
|
||||||
w.Focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
trayMenu.AddSeparator()
|
|
||||||
trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
|
|
||||||
go func() {
|
|
||||||
latest := fetchLatestRelease()
|
|
||||||
if latest == nil {
|
|
||||||
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, a := range latest.Assets {
|
|
||||||
if strings.Contains(a.Name, "darwin") {
|
|
||||||
updater.mu.Lock()
|
|
||||||
updater.latestURL = a.BrowserDownloadURL
|
|
||||||
updater.mu.Unlock()
|
|
||||||
msg := fmt.Sprintf("Version %s is available (you have %s). Install now?", latest.Version, version)
|
|
||||||
d := app.Dialog.Question().SetTitle("Update Available").SetMessage(msg)
|
|
||||||
yes := d.AddButton("Install")
|
|
||||||
yes.OnClick(func() { doUpdate(updater.latestURL) })
|
|
||||||
no := d.AddButton("Later")
|
|
||||||
d.SetDefaultButton(yes)
|
|
||||||
d.SetCancelButton(no)
|
|
||||||
d.Show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
|
|
||||||
}()
|
|
||||||
})
|
|
||||||
trayMenu.AddSeparator()
|
|
||||||
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
|
|
||||||
app.Quit()
|
|
||||||
})
|
|
||||||
systemTray.SetMenu(trayMenu)
|
|
||||||
|
|
||||||
ws := loadWindowState()
|
|
||||||
width, height := 1400, 900
|
|
||||||
if ws != nil {
|
|
||||||
width = ws.Width
|
|
||||||
height = ws.Height
|
|
||||||
}
|
|
||||||
|
|
||||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
|
||||||
Title: "Oikos",
|
|
||||||
Width: width,
|
|
||||||
Height: height,
|
|
||||||
MinWidth: 1024,
|
|
||||||
MinHeight: 700,
|
|
||||||
URL: "/?desktop=1",
|
|
||||||
})
|
|
||||||
|
|
||||||
if ws != nil {
|
|
||||||
window.SetPosition(ws.X, ws.Y)
|
|
||||||
} else {
|
|
||||||
window.Center()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
|
||||||
window.Hide()
|
|
||||||
e.Cancel()
|
|
||||||
})
|
|
||||||
|
|
||||||
window.Show()
|
|
||||||
|
|
||||||
systemTray.AttachWindow(window)
|
|
||||||
systemTray.Run()
|
|
||||||
|
|
||||||
app.OnShutdown(func() {
|
|
||||||
saveWindowState(window)
|
|
||||||
})
|
|
||||||
|
|
||||||
go pollDashboard(loadConfig())
|
|
||||||
go checkUpdates()
|
|
||||||
|
|
||||||
err = app.Run()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<svg width="88" height="88" viewBox="0 0 110 120" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(9, 10) scale(0.9)">
|
|
||||||
<path fill="#ffffff" d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 690 B |
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "oikos",
|
|
||||||
"outputfilename": "oikos-desktop",
|
|
||||||
"frontend:dir": "frontend",
|
|
||||||
"author": {
|
|
||||||
"name": "Hubris",
|
|
||||||
"email": "d.toro.v@pm.me"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,11 +57,18 @@ 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, openrouterAPIKey string) (*agent, error) {
|
||||||
system := loadSoul()
|
system := loadSoul()
|
||||||
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
apiKey := openrouterAPIKey
|
||||||
model := os.Getenv("NOMOS_MODEL")
|
model := os.Getenv("NOMOS_MODEL")
|
||||||
if model == "" {
|
if model == "" {
|
||||||
// v4-pro over v4-flash: the flash tier over-narrates, occasionally
|
// v4-pro over v4-flash: the flash tier over-narrates, occasionally
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +181,11 @@ type agentEvent struct {
|
|||||||
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)) {
|
||||||
@@ -368,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() {
|
||||||
@@ -400,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
|
||||||
@@ -456,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)
|
||||||
@@ -491,7 +515,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
slog.Warn("nomos: run retry cap hit — refusing dispatch",
|
slog.Warn("nomos: run retry cap hit — refusing dispatch",
|
||||||
"target", t, "failures", n, "session", sessionID)
|
"target", t, "failures", n, "session", sessionID)
|
||||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args,
|
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args,
|
||||||
tc.Function.Arguments, directive, 0, false, correlationID)
|
tc.Function.Arguments, directive, 0, false, correlationID, totalTokens)
|
||||||
emit(agentEvent{
|
emit(agentEvent{
|
||||||
Type: "tool_result",
|
Type: "tool_result",
|
||||||
Data: map[string]any{"name": tc.Function.Name, "result": directive, "id": tc.ID, "retry_capped": true},
|
Data: map[string]any{"name": tc.Function.Name, "result": directive, "id": tc.ID, "retry_capped": true},
|
||||||
@@ -542,7 +566,7 @@ 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)
|
// Retry cap: dispatch errors (e.g. MCP client timeout)
|
||||||
// count toward the cap too. A command that keeps timing
|
// count toward the cap too. A command that keeps timing
|
||||||
@@ -572,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
348
cmd/nomos/mcp.go
Normal file
348
cmd/nomos/mcp.go
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── MCP Streamable HTTP client ────────────────────────────────────────
|
||||||
|
|
||||||
|
type mcpClient struct {
|
||||||
|
baseURL string
|
||||||
|
token string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it on every request (no dev-open bypass)
|
||||||
|
sessionID string
|
||||||
|
http *http.Client
|
||||||
|
nextID int
|
||||||
|
mu sync.Mutex // one client serializes its own MCP calls (the pool gives each session its own client, so this never blocks another session)
|
||||||
|
// toolsCache holds the last tools/list result. The tool list is static
|
||||||
|
// for the lifetime of one MCP connection — it only changes when the api
|
||||||
|
// process (re)registers tools, i.e. on a restart, which this client
|
||||||
|
// already detects and reacts to via reconnectLocked. Without this,
|
||||||
|
// buildTools (called at the start of EVERY chat turn, including every
|
||||||
|
// auto-continuation resume) paid a full tools/list round-trip every
|
||||||
|
// single time for a list that's almost always identical to the last one.
|
||||||
|
// Guarded separately from mu (not reused) so a cache check never
|
||||||
|
// contends with an in-flight doRequest call for a different method.
|
||||||
|
toolsMu sync.Mutex
|
||||||
|
toolsCache []toolDef
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMCPClient(baseURL, token string) (*mcpClient, error) {
|
||||||
|
c := &mcpClient{
|
||||||
|
baseURL: baseURL,
|
||||||
|
token: token,
|
||||||
|
http: &http.Client{Timeout: 120 * time.Second},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.doRequest("initialize", map[string]any{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": map[string]any{},
|
||||||
|
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("initialize: %w", err)
|
||||||
|
}
|
||||||
|
if resp.sessionID == "" {
|
||||||
|
return nil, fmt.Errorf("no session ID in initialize response")
|
||||||
|
}
|
||||||
|
c.sessionID = resp.sessionID
|
||||||
|
|
||||||
|
c.doRequest("notifications/initialized", map[string]any{})
|
||||||
|
|
||||||
|
slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...")
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mcpJSONRPCResponse struct {
|
||||||
|
sessionID string
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error json.RawMessage `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// errStaleSession signals that the MCP server rejected our session id (e.g.
|
||||||
|
// after an api/MCP restart), so the client should re-initialize and retry.
|
||||||
|
var errStaleSession = fmt.Errorf("mcp session stale")
|
||||||
|
|
||||||
|
// doRequest serializes MCP calls and transparently re-initializes the session
|
||||||
|
// if the server has forgotten it (common after an api redeploy), retrying the
|
||||||
|
// original call once. Without this, an api restart permanently breaks nomos
|
||||||
|
// until it is itself restarted.
|
||||||
|
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
resp, err := c.send(method, params)
|
||||||
|
if err != nil && method != "initialize" && errors.Is(err, errStaleSession) {
|
||||||
|
slog.Warn("nomos: mcp session stale, reconnecting")
|
||||||
|
if rerr := c.reconnectLocked(); rerr != nil {
|
||||||
|
return nil, fmt.Errorf("mcp reconnect: %w (original: %v)", rerr, err)
|
||||||
|
}
|
||||||
|
return c.send(method, params)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
|
||||||
|
func (c *mcpClient) reconnectLocked() error {
|
||||||
|
c.sessionID = ""
|
||||||
|
// A reconnect means the api process was restarted (or forgot us) — its
|
||||||
|
// tool registration may have changed, so the cached list is no longer
|
||||||
|
// trustworthy.
|
||||||
|
c.toolsMu.Lock()
|
||||||
|
c.toolsCache = nil
|
||||||
|
c.toolsMu.Unlock()
|
||||||
|
resp, err := c.send("initialize", map[string]any{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": map[string]any{},
|
||||||
|
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.sessionID == "" {
|
||||||
|
return fmt.Errorf("no session ID on re-initialize")
|
||||||
|
}
|
||||||
|
c.sessionID = resp.sessionID
|
||||||
|
_, _ = c.send("notifications/initialized", map[string]any{})
|
||||||
|
slog.Info("nomos: mcp reconnected", "session", c.sessionID[:16]+"...")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// send performs one MCP round-trip. It does not lock; callers hold c.mu.
|
||||||
|
func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
||||||
|
c.nextID++
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
"id": c.nextID,
|
||||||
|
})
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, c.baseURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
if c.sessionID != "" {
|
||||||
|
req.Header.Set("Mcp-Session-Id", c.sessionID)
|
||||||
|
}
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// A rejected/unknown session comes back as 4xx (commonly 400/404).
|
||||||
|
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
|
||||||
|
return nil, errStaleSession
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &mcpJSONRPCResponse{}
|
||||||
|
result.sessionID = resp.Header.Get("Mcp-Session-Id")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
|
gotData := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "data: ") {
|
||||||
|
gotData = true
|
||||||
|
data := line[6:]
|
||||||
|
if err := json.Unmarshal([]byte(data), result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, fmt.Errorf("rpc error: %s", string(result.Error))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty body with no result and a session set: the server likely dropped
|
||||||
|
// our session. Notifications legitimately return no data, so exempt them.
|
||||||
|
if !gotData && result.Result == nil && method != "notifications/initialized" {
|
||||||
|
return nil, errStaleSession
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.sessionID != "" {
|
||||||
|
c.sessionID = result.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mcpClient) callTool(name string, args map[string]any) (any, error) {
|
||||||
|
resp, err := c.doRequest("tools/call", map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"arguments": args,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolResult struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Result, &toolResult); err != nil {
|
||||||
|
return string(resp.Result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var texts []string
|
||||||
|
for _, c := range toolResult.Content {
|
||||||
|
if c.Type == "text" {
|
||||||
|
var parsed any
|
||||||
|
if json.Unmarshal([]byte(c.Text), &parsed) == nil {
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
texts = append(texts, c.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(texts) == 1 {
|
||||||
|
return texts[0], nil
|
||||||
|
}
|
||||||
|
return texts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mcpClient) listTools() ([]string, error) {
|
||||||
|
resp, err := c.doRequest("tools/list", map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tr struct {
|
||||||
|
Tools []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Result, &tr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, t := range tr.Tools {
|
||||||
|
names = append(names, t.Name)
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mcpClient) close() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Per-session MCP client pool ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A single shared mcpClient serializes EVERY tool call across EVERY
|
||||||
|
// concurrently-running task through one mutex (see mcpClient.mu) — `run`
|
||||||
|
// executes its SSH command synchronously inside that lock and is capped at
|
||||||
|
// up to 10 minutes, so one task mid-`run` stalled every other task's tool
|
||||||
|
// calls, even trivial reads, behind it. The MCP *server* has no per-
|
||||||
|
// connection state to protect (newServer in internal/mcp/server.go returns
|
||||||
|
// one shared *mcp.Server instance whose tool handlers close only over the DB
|
||||||
|
// pool, which is already safe for concurrent use) — the mutex existed purely
|
||||||
|
// because the *client* reused one stateful transport session, not because
|
||||||
|
// the server needed it. Giving each task's own session its own client
|
||||||
|
// removes the cross-task serialization entirely: a task's own tool calls
|
||||||
|
// stay sequential (which they already are — the agent loop calls tools one
|
||||||
|
// at a time within a turn), but no longer block anyone else's.
|
||||||
|
type mcpClientPool struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[string]*pooledMCPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type pooledMCPClient struct {
|
||||||
|
client *mcpClient
|
||||||
|
lastUsed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMCPClientPool(baseURL, token string) *mcpClientPool {
|
||||||
|
return &mcpClientPool{baseURL: baseURL, token: token, clients: make(map[string]*pooledMCPClient)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns the client for sessionID, creating and initializing one (a
|
||||||
|
// real MCP handshake) on first use. Session ids that don't identify a real
|
||||||
|
// persisted conversation ("" / "ephemeral", the no-DB-store path; "query",
|
||||||
|
// the structured /query endpoint) still get exactly one dedicated,
|
||||||
|
// reused client each via the same map — just keyed on a fixed string instead
|
||||||
|
// of a real session id — so that traffic doesn't pay a fresh handshake per
|
||||||
|
// request while still never sharing a connection with an actual task.
|
||||||
|
func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) {
|
||||||
|
key := sessionID
|
||||||
|
if key == "" {
|
||||||
|
key = "ephemeral"
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if pc, ok := p.clients[key]; ok {
|
||||||
|
pc.lastUsed = time.Now()
|
||||||
|
p.mu.Unlock()
|
||||||
|
return pc.client, nil
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Initialize outside the lock — it's a network round-trip, and holding
|
||||||
|
// the pool mutex for it would serialize unrelated sessions' first calls
|
||||||
|
// behind each other, undermining the whole point of this pool.
|
||||||
|
c, err := newMCPClient(p.baseURL, p.token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
// Another goroutine may have created one for the same key while we were
|
||||||
|
// initializing (two of this session's tool calls racing on a cold
|
||||||
|
// start); keep whichever won, close out the loser's connection (a no-op
|
||||||
|
// today, but future-proof if mcpClient.close ever does real teardown).
|
||||||
|
if existing, ok := p.clients[key]; ok {
|
||||||
|
p.mu.Unlock()
|
||||||
|
c.close()
|
||||||
|
return existing.client, nil
|
||||||
|
}
|
||||||
|
p.clients[key] = &pooledMCPClient{client: c, lastUsed: time.Now()}
|
||||||
|
p.mu.Unlock()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpClientIdleTimeout is how long an idle session's MCP client is kept
|
||||||
|
// before eviction — long enough to outlive a single slow `run` (capped at 10
|
||||||
|
// minutes server-side) plus normal think-time between a task's tool calls,
|
||||||
|
// short enough not to accumulate one abandoned connection per finished task
|
||||||
|
// forever.
|
||||||
|
const mcpClientIdleTimeout = 20 * time.Minute
|
||||||
|
|
||||||
|
// sweep evicts clients idle past mcpClientIdleTimeout. Call on a ticker.
|
||||||
|
func (p *mcpClientPool) sweep() {
|
||||||
|
cutoff := time.Now().Add(-mcpClientIdleTimeout)
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for key, pc := range p.clients {
|
||||||
|
if pc.lastUsed.Before(cutoff) {
|
||||||
|
pc.client.close()
|
||||||
|
delete(p.clients, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *mcpClientPool) closeAll() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for key, pc := range p.clients {
|
||||||
|
pc.client.close()
|
||||||
|
delete(p.clients, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
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")
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,7 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/safego"
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
"github.com/google/uuid"
|
"github.com/dtoro/oikos/internal/secrets"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,13 +24,14 @@ func main() {
|
|||||||
fmt.Fprintln(os.Stderr, "usage: nomos serve")
|
fmt.Fprintln(os.Stderr, "usage: nomos serve")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if os.Args[1] == "healthcheck" {
|
||||||
|
runHealthcheck()
|
||||||
|
return
|
||||||
|
}
|
||||||
mcpURL := os.Getenv("NOMOS_MCP_URL")
|
mcpURL := os.Getenv("NOMOS_MCP_URL")
|
||||||
if mcpURL == "" {
|
if mcpURL == "" {
|
||||||
mcpURL = "http://localhost:8090/mcp"
|
mcpURL = "http://localhost:8090/mcp"
|
||||||
}
|
}
|
||||||
// api's combinedAuth requires a bearer token on every request (no
|
|
||||||
// dev-open bypass — plans/2026-07-12-wails-desktop-app.md 0.4); this is
|
|
||||||
// the same shared secret api validates against (OIKOS_MCP_BEARER_TOKEN).
|
|
||||||
mcpToken := os.Getenv("OIKOS_MCP_BEARER_TOKEN")
|
mcpToken := os.Getenv("OIKOS_MCP_BEARER_TOKEN")
|
||||||
|
|
||||||
agentSlug := os.Getenv("NOMOS_AGENT_SLUG")
|
agentSlug := os.Getenv("NOMOS_AGENT_SLUG")
|
||||||
@@ -46,6 +44,32 @@ func main() {
|
|||||||
databaseURL = os.Getenv("OIKOS_DATABASE_URL")
|
databaseURL = os.Getenv("OIKOS_DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sec := secrets.NewManagerFromConfig(
|
||||||
|
os.Getenv("OIKOS_INFISICAL_SITE_URL"),
|
||||||
|
os.Getenv("OIKOS_INFISICAL_CLIENT_ID"),
|
||||||
|
os.Getenv("OIKOS_INFISICAL_CLIENT_SECRET"),
|
||||||
|
os.Getenv("OIKOS_INFISICAL_PROJECT_ID"),
|
||||||
|
os.Getenv("OIKOS_INFISICAL_ENV"),
|
||||||
|
os.Getenv("OIKOS_SECRETS_DIR"),
|
||||||
|
)
|
||||||
|
var openrouterAPIKey string
|
||||||
|
var secretsResolved int
|
||||||
|
if sec != nil {
|
||||||
|
resCtx, resCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
if v := secrets.ResolveSecret(resCtx, sec, "mcp_bearer-token", ""); v != "" {
|
||||||
|
mcpToken = v
|
||||||
|
secretsResolved++
|
||||||
|
}
|
||||||
|
openrouterAPIKey = secrets.ResolveSecret(resCtx, sec, "openrouter_api-key", os.Getenv("OPENROUTER_API_KEY"))
|
||||||
|
if openrouterAPIKey != "" && openrouterAPIKey != os.Getenv("OPENROUTER_API_KEY") {
|
||||||
|
secretsResolved++
|
||||||
|
}
|
||||||
|
resCancel()
|
||||||
|
if secretsResolved > 0 {
|
||||||
|
slog.Info("nomos: secrets resolved from Infisical", "count", secretsResolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "serve":
|
case "serve":
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
@@ -75,7 +99,7 @@ func main() {
|
|||||||
defer st.close()
|
defer st.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
nAgent, err := newAgent(ctx, clientPool, st, agentSlug)
|
nAgent, err := newAgent(ctx, clientPool, st, agentSlug, openrouterAPIKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("nomos: agent init", "error", err)
|
slog.Error("nomos: agent init", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -161,6 +185,26 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runHealthcheck() {
|
||||||
|
addr := os.Getenv("NOMOS_LISTEN")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8092"
|
||||||
|
}
|
||||||
|
host := addr
|
||||||
|
if strings.HasPrefix(host, ":") {
|
||||||
|
host = "127.0.0.1" + host
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := client.Get("http://" + host + "/healthz")
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
|
func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
|
||||||
data, _ := json.Marshal(event)
|
data, _ := json.Marshal(event)
|
||||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
@@ -186,12 +230,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.]"
|
||||||
@@ -200,7 +254,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
|
||||||
}
|
}
|
||||||
@@ -217,6 +271,17 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
|
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
|
||||||
|
|
||||||
@@ -268,111 +333,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.
|
|
||||||
// P2.9 (2026-07-20): prefer the goal as the title when one is set —
|
|
||||||
// the first assistant text is often a greeting or narrative that
|
|
||||||
// doesn't describe the task ("Hey! 👋 Nomos here, running on
|
|
||||||
// mac-mini:8092..."). The goal is the operator's actual intent.
|
|
||||||
// Sessions that never call set_goal (pure Q&A) fall back to the
|
|
||||||
// assistant text, which is still better than the raw user message.
|
|
||||||
if finalText != "" && sessionID != "ephemeral" {
|
|
||||||
var goalTitle string
|
|
||||||
if sess, gerr := st.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
|
|
||||||
goalTitle = truncate(sess.Goal, 120)
|
|
||||||
}
|
|
||||||
title := goalTitle
|
|
||||||
if title == "" {
|
|
||||||
title = truncate(finalText, 80)
|
|
||||||
}
|
|
||||||
if title != "" {
|
|
||||||
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) {
|
||||||
@@ -483,7 +502,8 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
|||||||
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
|
||||||
@@ -673,338 +693,3 @@ func truncate(s string, n int) string {
|
|||||||
}
|
}
|
||||||
return s[:n] + "..."
|
return s[:n] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── MCP Streamable HTTP client ────────────────────────────────────────
|
|
||||||
|
|
||||||
type mcpClient struct {
|
|
||||||
baseURL string
|
|
||||||
token string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it on every request (no dev-open bypass)
|
|
||||||
sessionID string
|
|
||||||
http *http.Client
|
|
||||||
nextID int
|
|
||||||
mu sync.Mutex // this client is one stateful MCP session; serialize ITS OWN calls
|
|
||||||
|
|
||||||
// toolsCache holds the last tools/list result. The tool list is static
|
|
||||||
// for the lifetime of one MCP connection — it only changes when the api
|
|
||||||
// process (re)registers tools, i.e. on a restart, which this client
|
|
||||||
// already detects and reacts to via reconnectLocked. Without this,
|
|
||||||
// buildTools (called at the start of EVERY chat turn, including every
|
|
||||||
// auto-continuation resume) paid a full tools/list round-trip every
|
|
||||||
// single time for a list that's almost always identical to the last one.
|
|
||||||
// Guarded separately from mu (not reused) so a cache check never
|
|
||||||
// contends with an in-flight doRequest call for a different method.
|
|
||||||
toolsMu sync.Mutex
|
|
||||||
toolsCache []toolDef
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMCPClient(baseURL, token string) (*mcpClient, error) {
|
|
||||||
c := &mcpClient{
|
|
||||||
baseURL: baseURL,
|
|
||||||
token: token,
|
|
||||||
http: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.doRequest("initialize", map[string]any{
|
|
||||||
"protocolVersion": "2024-11-05",
|
|
||||||
"capabilities": map[string]any{},
|
|
||||||
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("initialize: %w", err)
|
|
||||||
}
|
|
||||||
if resp.sessionID == "" {
|
|
||||||
return nil, fmt.Errorf("no session ID in initialize response")
|
|
||||||
}
|
|
||||||
c.sessionID = resp.sessionID
|
|
||||||
|
|
||||||
c.doRequest("notifications/initialized", map[string]any{})
|
|
||||||
|
|
||||||
slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...")
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type mcpJSONRPCResponse struct {
|
|
||||||
sessionID string
|
|
||||||
Result json.RawMessage `json:"result"`
|
|
||||||
Error json.RawMessage `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// errStaleSession signals that the MCP server rejected our session id (e.g.
|
|
||||||
// after an api/MCP restart), so the client should re-initialize and retry.
|
|
||||||
var errStaleSession = fmt.Errorf("mcp session stale")
|
|
||||||
|
|
||||||
// doRequest serializes MCP calls and transparently re-initializes the session
|
|
||||||
// if the server has forgotten it (common after an api redeploy), retrying the
|
|
||||||
// original call once. Without this, an api restart permanently breaks nomos
|
|
||||||
// until it is itself restarted.
|
|
||||||
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
resp, err := c.send(method, params)
|
|
||||||
if err != nil && method != "initialize" && errors.Is(err, errStaleSession) {
|
|
||||||
slog.Warn("nomos: mcp session stale, reconnecting")
|
|
||||||
if rerr := c.reconnectLocked(); rerr != nil {
|
|
||||||
return nil, fmt.Errorf("mcp reconnect: %w (original: %v)", rerr, err)
|
|
||||||
}
|
|
||||||
return c.send(method, params)
|
|
||||||
}
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
|
|
||||||
func (c *mcpClient) reconnectLocked() error {
|
|
||||||
c.sessionID = ""
|
|
||||||
// A reconnect means the api process was restarted (or forgot us) — its
|
|
||||||
// tool registration may have changed, so the cached list is no longer
|
|
||||||
// trustworthy.
|
|
||||||
c.toolsMu.Lock()
|
|
||||||
c.toolsCache = nil
|
|
||||||
c.toolsMu.Unlock()
|
|
||||||
resp, err := c.send("initialize", map[string]any{
|
|
||||||
"protocolVersion": "2024-11-05",
|
|
||||||
"capabilities": map[string]any{},
|
|
||||||
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if resp.sessionID == "" {
|
|
||||||
return fmt.Errorf("no session ID on re-initialize")
|
|
||||||
}
|
|
||||||
c.sessionID = resp.sessionID
|
|
||||||
_, _ = c.send("notifications/initialized", map[string]any{})
|
|
||||||
slog.Info("nomos: mcp reconnected", "session", c.sessionID[:16]+"...")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// send performs one MCP round-trip. It does not lock; callers hold c.mu.
|
|
||||||
func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
|
||||||
c.nextID++
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"method": method,
|
|
||||||
"params": params,
|
|
||||||
"id": c.nextID,
|
|
||||||
})
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, c.baseURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
|
||||||
if c.sessionID != "" {
|
|
||||||
req.Header.Set("Mcp-Session-Id", c.sessionID)
|
|
||||||
}
|
|
||||||
if c.token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// A rejected/unknown session comes back as 4xx (commonly 400/404).
|
|
||||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
|
|
||||||
return nil, errStaleSession
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &mcpJSONRPCResponse{}
|
|
||||||
result.sessionID = resp.Header.Get("Mcp-Session-Id")
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
||||||
gotData := false
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if strings.HasPrefix(line, "data: ") {
|
|
||||||
gotData = true
|
|
||||||
data := line[6:]
|
|
||||||
if err := json.Unmarshal([]byte(data), result); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse response: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
return nil, fmt.Errorf("rpc error: %s", string(result.Error))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty body with no result and a session set: the server likely dropped
|
|
||||||
// our session. Notifications legitimately return no data, so exempt them.
|
|
||||||
if !gotData && result.Result == nil && method != "notifications/initialized" {
|
|
||||||
return nil, errStaleSession
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.sessionID != "" {
|
|
||||||
c.sessionID = result.sessionID
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *mcpClient) callTool(name string, args map[string]any) (any, error) {
|
|
||||||
resp, err := c.doRequest("tools/call", map[string]any{
|
|
||||||
"name": name,
|
|
||||||
"arguments": args,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var toolResult struct {
|
|
||||||
Content []struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
} `json:"content"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(resp.Result, &toolResult); err != nil {
|
|
||||||
return string(resp.Result), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var texts []string
|
|
||||||
for _, c := range toolResult.Content {
|
|
||||||
if c.Type == "text" {
|
|
||||||
var parsed any
|
|
||||||
if json.Unmarshal([]byte(c.Text), &parsed) == nil {
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
texts = append(texts, c.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(texts) == 1 {
|
|
||||||
return texts[0], nil
|
|
||||||
}
|
|
||||||
return texts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *mcpClient) listTools() ([]string, error) {
|
|
||||||
resp, err := c.doRequest("tools/list", map[string]any{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var tr struct {
|
|
||||||
Tools []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
} `json:"tools"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(resp.Result, &tr); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
for _, t := range tr.Tools {
|
|
||||||
names = append(names, t.Name)
|
|
||||||
}
|
|
||||||
return names, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *mcpClient) close() {
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Per-session MCP client pool ────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// A single shared mcpClient serializes EVERY tool call across EVERY
|
|
||||||
// concurrently-running task through one mutex (see mcpClient.mu) — `run`
|
|
||||||
// executes its SSH command synchronously inside that lock and is capped at
|
|
||||||
// up to 10 minutes, so one task mid-`run` stalled every other task's tool
|
|
||||||
// calls, even trivial reads, behind it. The MCP *server* has no per-
|
|
||||||
// connection state to protect (newServer in internal/mcp/server.go returns
|
|
||||||
// one shared *mcp.Server instance whose tool handlers close only over the DB
|
|
||||||
// pool, which is already safe for concurrent use) — the mutex existed purely
|
|
||||||
// because the *client* reused one stateful transport session, not because
|
|
||||||
// the server needed it. Giving each task's own session its own client
|
|
||||||
// removes the cross-task serialization entirely: a task's own tool calls
|
|
||||||
// stay sequential (which they already are — the agent loop calls tools one
|
|
||||||
// at a time within a turn), but no longer block anyone else's.
|
|
||||||
type mcpClientPool struct {
|
|
||||||
baseURL string
|
|
||||||
token string
|
|
||||||
mu sync.Mutex
|
|
||||||
clients map[string]*pooledMCPClient
|
|
||||||
}
|
|
||||||
|
|
||||||
type pooledMCPClient struct {
|
|
||||||
client *mcpClient
|
|
||||||
lastUsed time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMCPClientPool(baseURL, token string) *mcpClientPool {
|
|
||||||
return &mcpClientPool{baseURL: baseURL, token: token, clients: make(map[string]*pooledMCPClient)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// get returns the client for sessionID, creating and initializing one (a
|
|
||||||
// real MCP handshake) on first use. Session ids that don't identify a real
|
|
||||||
// persisted conversation ("" / "ephemeral", the no-DB-store path; "query",
|
|
||||||
// the structured /query endpoint) still get exactly one dedicated,
|
|
||||||
// reused client each via the same map — just keyed on a fixed string instead
|
|
||||||
// of a real session id — so that traffic doesn't pay a fresh handshake per
|
|
||||||
// request while still never sharing a connection with an actual task.
|
|
||||||
func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) {
|
|
||||||
key := sessionID
|
|
||||||
if key == "" {
|
|
||||||
key = "ephemeral"
|
|
||||||
}
|
|
||||||
|
|
||||||
p.mu.Lock()
|
|
||||||
if pc, ok := p.clients[key]; ok {
|
|
||||||
pc.lastUsed = time.Now()
|
|
||||||
p.mu.Unlock()
|
|
||||||
return pc.client, nil
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
// Initialize outside the lock — it's a network round-trip, and holding
|
|
||||||
// the pool mutex for it would serialize unrelated sessions' first calls
|
|
||||||
// behind each other, undermining the whole point of this pool.
|
|
||||||
c, err := newMCPClient(p.baseURL, p.token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
p.mu.Lock()
|
|
||||||
// Another goroutine may have created one for the same key while we were
|
|
||||||
// initializing (two of this session's tool calls racing on a cold
|
|
||||||
// start); keep whichever won, close out the loser's connection (a no-op
|
|
||||||
// today, but future-proof if mcpClient.close ever does real teardown).
|
|
||||||
if existing, ok := p.clients[key]; ok {
|
|
||||||
p.mu.Unlock()
|
|
||||||
c.close()
|
|
||||||
return existing.client, nil
|
|
||||||
}
|
|
||||||
p.clients[key] = &pooledMCPClient{client: c, lastUsed: time.Now()}
|
|
||||||
p.mu.Unlock()
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mcpClientIdleTimeout is how long an idle session's MCP client is kept
|
|
||||||
// before eviction — long enough to outlive a single slow `run` (capped at 10
|
|
||||||
// minutes server-side) plus normal think-time between a task's tool calls,
|
|
||||||
// short enough not to accumulate one abandoned connection per finished task
|
|
||||||
// forever.
|
|
||||||
const mcpClientIdleTimeout = 20 * time.Minute
|
|
||||||
|
|
||||||
// sweep evicts clients idle past mcpClientIdleTimeout. Call on a ticker.
|
|
||||||
func (p *mcpClientPool) sweep() {
|
|
||||||
cutoff := time.Now().Add(-mcpClientIdleTimeout)
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
for key, pc := range p.clients {
|
|
||||||
if pc.lastUsed.Before(cutoff) {
|
|
||||||
pc.client.close()
|
|
||||||
delete(p.clients, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *mcpClientPool) closeAll() {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
for key, pc := range p.clients {
|
|
||||||
pc.client.close()
|
|
||||||
delete(p.clients, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,9 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +26,13 @@ const maxToolResultSize = 4096
|
|||||||
// The caller translates this into a directive tool result.
|
// The caller translates this into a directive tool result.
|
||||||
var errPlanInFlight = errors.New("plan already in flight")
|
var errPlanInFlight = errors.New("plan already in flight")
|
||||||
|
|
||||||
|
// errPlanStepNotFound is returned by updatePlanStep when no step matches the
|
||||||
|
// given seq in the CURRENT (MAX) generation — either the seq is out of range,
|
||||||
|
// or (after a re-plan) the model addressed a stale 1-based number. seq is
|
||||||
|
// generation-relative, so this never resurrects a superseded generation's row.
|
||||||
|
// The caller translates it into a directive tool result (P0.1).
|
||||||
|
var errPlanStepNotFound = errors.New("plan step not found in current generation")
|
||||||
|
|
||||||
type store struct {
|
type store struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -171,6 +179,15 @@ func (s *store) createTaskEntity(ctx context.Context, sessionID, title string) s
|
|||||||
`UPDATE agent_sessions SET entity_id = $1 WHERE id = $2`, entityID, sessionID); err != nil {
|
`UPDATE agent_sessions SET entity_id = $1 WHERE id = $2`, entityID, sessionID); err != nil {
|
||||||
slog.Warn("nomos: could not link task entity", "session", sessionID, "error", err)
|
slog.Warn("nomos: could not link task entity", "session", sessionID, "error", err)
|
||||||
}
|
}
|
||||||
|
// Graph edge: task —involves→ agent:nomos (gives every task at least one
|
||||||
|
// edge from creation, even if no run calls are ever made).
|
||||||
|
s.pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, id, 'involves', '{"by":"nomos"}'::jsonb, now()
|
||||||
|
FROM entities WHERE slug = 'agent:nomos'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships r
|
||||||
|
WHERE r.source_id = $1 AND r.target_id = entities.id AND r.type = 'involves' AND r.valid_to IS NULL)`,
|
||||||
|
entityID)
|
||||||
return entityID.String()
|
return entityID.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,10 +816,11 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
|||||||
// Replace any prior plan steps (done/running/pending/...) as `replaced`.
|
// Replace any prior plan steps (done/running/pending/...) as `replaced`.
|
||||||
// The rows are kept for the generation counter + audit trail; proposePlan
|
// The rows are kept for the generation counter + audit trail; proposePlan
|
||||||
// excludes `replaced` from its in-flight check, so the next propose_plan
|
// excludes `replaced` from its in-flight check, so the next propose_plan
|
||||||
// takes the fresh-generation path.
|
// takes the fresh-generation path. replaced_reason records the cause
|
||||||
|
// (2026-08-04 plan-step integrity audit).
|
||||||
s.pool.Exec(ctx,
|
s.pool.Exec(ctx,
|
||||||
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
`UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
||||||
sessionID)
|
sessionID, "goal superseded")
|
||||||
if _, err := s.pool.Exec(ctx,
|
if _, err := s.pool.Exec(ctx,
|
||||||
`UPDATE agent_sessions SET goal = $2, status = 'executing', title = $2, last_active_at = now() WHERE id = $1`,
|
`UPDATE agent_sessions SET goal = $2, status = 'executing', title = $2, last_active_at = now() WHERE id = $1`,
|
||||||
sessionID, goal); err != nil {
|
sessionID, goal); err != nil {
|
||||||
@@ -842,6 +860,14 @@ func (s *store) reopenSession(ctx context.Context, sessionID string) bool {
|
|||||||
s.pool.Exec(ctx,
|
s.pool.Exec(ctx,
|
||||||
`UPDATE agent_sessions SET status = 'executing', outcome = NULL, summary = NULL, last_active_at = now() WHERE id = $1`,
|
`UPDATE agent_sessions SET status = 'executing', outcome = NULL, summary = NULL, last_active_at = now() WHERE id = $1`,
|
||||||
sessionID)
|
sessionID)
|
||||||
|
// Mark the prior plan's steps as replaced so the P1 plan-first gate in
|
||||||
|
// classifyAndGate forces a fresh propose_plan before any run. Without
|
||||||
|
// this, the agent could resume a session and call run against the old
|
||||||
|
// (completed) plan — exactly what caused the ZimaOS continuation to
|
||||||
|
// have 81 ad-hoc tool calls with zero plan structure (2026-08-04).
|
||||||
|
s.pool.Exec(ctx,
|
||||||
|
`UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
||||||
|
sessionID, "session reopened — awaiting new plan")
|
||||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.reopened", s.taskEntityPtr(ctx, sessionID),
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.reopened", s.taskEntityPtr(ctx, sessionID),
|
||||||
"info", "nomos", sessionID, map[string]any{"prior_status": currentStatus})
|
"info", "nomos", sessionID, map[string]any{"prior_status": currentStatus})
|
||||||
return true
|
return true
|
||||||
@@ -879,16 +905,15 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
var startSeq int
|
|
||||||
var anyStarted bool
|
var anyStarted bool
|
||||||
// `replaced` steps (from a prior plan generation superseded by a
|
// `replaced` steps (from a prior plan generation superseded by a
|
||||||
// follow-up sub-task — see reopenSession) are excluded: they prove a
|
// follow-up sub-task — see setGoal/reopenSession) are excluded: they
|
||||||
// prior plan was completed and superseded, not that a plan is in flight.
|
// prove a prior plan was completed and superseded, not that a plan is in
|
||||||
// Without this exclusion, reopenSession's `replaced` marking would be
|
// flight. Without this exclusion, setGoal's `replaced` marking would be
|
||||||
// useless — propose_plan would still refuse on the follow-up.
|
// useless — propose_plan would still refuse on the follow-up.
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status NOT IN ('pending', 'replaced')), false)
|
SELECT COALESCE(bool_or(status NOT IN ('pending', 'replaced')), false)
|
||||||
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&startSeq, &anyStarted); err != nil {
|
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&anyStarted); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if anyStarted {
|
if anyStarted {
|
||||||
@@ -898,35 +923,29 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
|||||||
return nil, errPlanInFlight
|
return nil, errPlanInFlight
|
||||||
}
|
}
|
||||||
// Fresh/revise: mark any prior PENDING steps as `replaced` (not DELETE).
|
// Fresh/revise: mark any prior PENDING steps as `replaced` (not DELETE).
|
||||||
// This preserves the rows for the generation counter (MAX(generation)+1
|
// The rows are kept for the generation counter (MAX(generation)+1 below)
|
||||||
// below) and the plan_generations eval assertion. Without this, a first
|
// and the plan_generations eval assertion. `replaced` steps are excluded
|
||||||
// plan that was proposed but never executed (all pending) would be
|
// from the anyStarted check above, so they don't block this proposal.
|
||||||
// wiped, resetting the counter to 1 — making a follow-up's plan look
|
// replaced_reason records the cause — required by the plan-step integrity
|
||||||
// like generation 1 instead of 2. `replaced` steps are excluded from
|
// gate (2026-08-04 session audit).
|
||||||
// the anyStarted check above, so they don't block the fresh proposal.
|
|
||||||
if _, err := tx.Exec(ctx,
|
if _, err := tx.Exec(ctx,
|
||||||
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`,
|
`UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`,
|
||||||
sessionID); err != nil {
|
sessionID, "superseded by new plan generation"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// startSeq keeps the max(seq) from the query above: if prior steps
|
|
||||||
// exist (replaced or done), the new generation's steps start after them
|
|
||||||
// (no seq collisions across generations). If no rows exist (first plan),
|
|
||||||
// startSeq is 0 and the first step is seq 1.
|
|
||||||
|
|
||||||
// Resolve the generation number for this plan. Generation 1 is the
|
// nextGen: generation 1 for the first plan, MAX(generation)+1 for every
|
||||||
// initial plan; a genuine revise (which currently goes through the same
|
// revise/follow-up (prior rows were marked `replaced` above, not deleted,
|
||||||
// fresh-start path above because all steps were pending) resets to 1
|
// so the counter survives). seq is generation-relative — it resets to
|
||||||
// since the DELETE wiped the prior rows. The column is wired here so a
|
// 1..N for this generation, so (session_id, generation, seq) is the
|
||||||
// future explicit mid-flight revise path can increment it.
|
// addressing key and the model's 1-based update_plan_step always maps to
|
||||||
|
// the CURRENT plan after a re-plan (P0.1).
|
||||||
var nextGen int
|
var nextGen int
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
SELECT COALESCE(MAX(generation), 0) + 1
|
SELECT COALESCE(MAX(generation), 0) + 1
|
||||||
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&nextGen); err != nil {
|
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&nextGen); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// After the DELETE above, no rows remain, so MAX(generation) is NULL →
|
|
||||||
// nextGen = 1. (Keep the query for the future revise path; it's cheap.)
|
|
||||||
|
|
||||||
out := make([]map[string]any, 0, len(steps))
|
out := make([]map[string]any, 0, len(steps))
|
||||||
for i, st := range steps {
|
for i, st := range steps {
|
||||||
@@ -934,7 +953,7 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
|||||||
if st.TargetSlug != "" {
|
if st.TargetSlug != "" {
|
||||||
targetSlug = &st.TargetSlug
|
targetSlug = &st.TargetSlug
|
||||||
}
|
}
|
||||||
seq := startSeq + i + 1
|
seq := i + 1
|
||||||
var id uuid.UUID
|
var id uuid.UUID
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug, generation)
|
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug, generation)
|
||||||
@@ -973,10 +992,27 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
|||||||
// be marked complete while an earlier step is still pending, preventing the
|
// be marked complete while an earlier step is still pending, preventing the
|
||||||
// agent from marking step 5 done before step 4 (observed in production: the
|
// agent from marking step 5 done before step 4 (observed in production: the
|
||||||
// agent rushed to close all steps in a final turn, in reverse order).
|
// agent rushed to close all steps in a final turn, in reverse order).
|
||||||
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID string) error {
|
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID, replacedReason string) error {
|
||||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Resolve the CURRENT generation: seq is generation-relative (1-based
|
||||||
|
// within the plan the model is working), so (session_id, MAX(generation),
|
||||||
|
// seq) is the addressing key. A re-plan's superseded generations have
|
||||||
|
// their own seq space and must never be touched by a follow-up's
|
||||||
|
// update_plan_step — that was the root cause of "the plan was off"
|
||||||
|
// (gen-1 `replaced` rows resurrected as `done` while gen-2 work went
|
||||||
|
// unrecorded). The MAX(generation) step is by construction the active
|
||||||
|
// plan, never `replaced`, so this can't resurrect a superseded row (P0.1).
|
||||||
|
var curGen int
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(MAX(generation), 0) FROM session_plan_steps WHERE session_id = $1`,
|
||||||
|
sessionID).Scan(&curGen); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if curGen == 0 {
|
||||||
|
return errPlanStepNotFound
|
||||||
|
}
|
||||||
stamp := ""
|
stamp := ""
|
||||||
switch status {
|
switch status {
|
||||||
case "running":
|
case "running":
|
||||||
@@ -984,16 +1020,18 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
|
|||||||
case "done", "failed", "skipped", "blocked", "replaced":
|
case "done", "failed", "skipped", "blocked", "replaced":
|
||||||
stamp = ", finished_at = now()"
|
stamp = ", finished_at = now()"
|
||||||
}
|
}
|
||||||
// Completion ordering: for terminal states, check that no earlier step
|
// Completion ordering, scoped to the CURRENT generation: for terminal
|
||||||
// is still pending. Running steps can start out of order (the agent
|
// states, no earlier step in THIS plan may still be pending. Running
|
||||||
// may dispatch parallel work), but completion must be sequential.
|
// steps can start out of order (the agent may dispatch parallel work),
|
||||||
|
// but completion must be sequential. Earlier generations are superseded
|
||||||
|
// and irrelevant.
|
||||||
if status == "done" || status == "failed" || status == "skipped" || status == "blocked" {
|
if status == "done" || status == "failed" || status == "skipped" || status == "blocked" {
|
||||||
var blockedBy int
|
var blockedBy int
|
||||||
if err := s.pool.QueryRow(ctx, `
|
if err := s.pool.QueryRow(ctx, `
|
||||||
SELECT COALESCE(MIN(seq), 0)
|
SELECT COALESCE(MIN(seq), 0)
|
||||||
FROM session_plan_steps
|
FROM session_plan_steps
|
||||||
WHERE session_id = $1 AND seq < $2 AND status = 'pending'`,
|
WHERE session_id = $1 AND generation = $2 AND seq < $3 AND status = 'pending'`,
|
||||||
sessionID, seq).Scan(&blockedBy); err == nil && blockedBy > 0 {
|
sessionID, curGen, seq).Scan(&blockedBy); err == nil && blockedBy > 0 {
|
||||||
return fmt.Errorf("cannot complete step %d — step %d is still pending", seq, blockedBy)
|
return fmt.Errorf("cannot complete step %d — step %d is still pending", seq, blockedBy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1004,12 +1042,33 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
|
|||||||
var stepID uuid.UUID
|
var stepID uuid.UUID
|
||||||
var targetSlug *string
|
var targetSlug *string
|
||||||
// stamp is a fixed literal from the switch above — never user input.
|
// stamp is a fixed literal from the switch above — never user input.
|
||||||
if err := s.pool.QueryRow(ctx, `
|
// status <> 'replaced' is defense-in-depth: MAX(generation) can't hold a
|
||||||
UPDATE session_plan_steps
|
// replaced row, but if it ever could, this refuses the write instead of
|
||||||
SET status = $3, execution_id = COALESCE($4, execution_id)`+stamp+`
|
// resurrecting it. No matching row → errPlanStepNotFound (stale/out-of-range seq).
|
||||||
WHERE session_id = $1 AND seq = $2
|
if status == "replaced" && replacedReason != "" {
|
||||||
RETURNING id, target_slug`, sessionID, seq, status, execPtr).Scan(&stepID, &targetSlug); err != nil {
|
err := s.pool.QueryRow(ctx, `
|
||||||
return err
|
UPDATE session_plan_steps
|
||||||
|
SET status = $4, execution_id = COALESCE($5, execution_id), replaced_reason = $6`+stamp+`
|
||||||
|
WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced'
|
||||||
|
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr, replacedReason).Scan(&stepID, &targetSlug)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return errPlanStepNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE session_plan_steps
|
||||||
|
SET status = $4, execution_id = COALESCE($5, execution_id)`+stamp+`
|
||||||
|
WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced'
|
||||||
|
RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr).Scan(&stepID, &targetSlug)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return errPlanStepNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Anchor the event to the step's target entity when it has one, else the task.
|
// Anchor the event to the step's target entity when it has one, else the task.
|
||||||
entPtr := s.taskEntityPtr(ctx, sessionID)
|
entPtr := s.taskEntityPtr(ctx, sessionID)
|
||||||
@@ -1098,13 +1157,58 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
|||||||
if outcome != "success" {
|
if outcome != "success" {
|
||||||
closeStatus = "skipped"
|
closeStatus = "skipped"
|
||||||
}
|
}
|
||||||
|
// Auto-close only the CURRENT generation's in-flight steps — superseded
|
||||||
|
// generations were already resolved when their plan was replaced. Stamp
|
||||||
|
// started_at so no `done` step is left with a NULL start time (P0.1 fix
|
||||||
|
// 5), and emit a plan.step.finished event per closed step so the panel
|
||||||
|
// converges instead of freezing on "running" after the task completes
|
||||||
|
// (P1.1: no bulk plan-step status write without a corresponding event).
|
||||||
|
type closingStep struct {
|
||||||
|
id uuid.UUID
|
||||||
|
seq int
|
||||||
|
targetSlug *string
|
||||||
|
}
|
||||||
|
var toClose []closingStep
|
||||||
|
if rows, qerr := s.pool.Query(ctx, `
|
||||||
|
SELECT id, seq, target_slug FROM session_plan_steps
|
||||||
|
WHERE session_id = $1
|
||||||
|
AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1)
|
||||||
|
AND status IN ('pending', 'running')`, sessionID); qerr == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var cs closingStep
|
||||||
|
if err := rows.Scan(&cs.id, &cs.seq, &cs.targetSlug); err == nil {
|
||||||
|
toClose = append(toClose, cs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
if _, err := s.pool.Exec(ctx, `
|
if _, err := s.pool.Exec(ctx, `
|
||||||
UPDATE session_plan_steps
|
UPDATE session_plan_steps
|
||||||
SET status = $2, finished_at = COALESCE(finished_at, now())
|
SET status = $2,
|
||||||
WHERE session_id = $1 AND status IN ('pending', 'running')`,
|
started_at = COALESCE(started_at, now()),
|
||||||
|
finished_at = COALESCE(finished_at, now())
|
||||||
|
WHERE session_id = $1
|
||||||
|
AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1)
|
||||||
|
AND status IN ('pending', 'running')`,
|
||||||
sessionID, closeStatus); err != nil {
|
sessionID, closeStatus); err != nil {
|
||||||
slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err)
|
slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err)
|
||||||
}
|
}
|
||||||
|
// Emit one plan.step.finished per closed step so the live panel advances
|
||||||
|
// (mirrors updatePlanStep's event). A bulk UPDATE that skips the event
|
||||||
|
// bus guarantees a stale panel — the rule is: no plan-step status change
|
||||||
|
// without a corresponding event.
|
||||||
|
taskEnt := s.taskEntityPtr(ctx, sessionID)
|
||||||
|
for _, cs := range toClose {
|
||||||
|
evEnt := taskEnt
|
||||||
|
if cs.targetSlug != nil && *cs.targetSlug != "" {
|
||||||
|
var tid uuid.UUID
|
||||||
|
if s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, *cs.targetSlug).Scan(&tid) == nil {
|
||||||
|
evEnt = &tid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.step.finished", evEnt, "info", "nomos", sessionID,
|
||||||
|
map[string]any{"step_id": cs.id.String(), "seq": cs.seq, "status": closeStatus})
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up assent and destructive window keys from autonomy_settings.
|
// Clean up assent and destructive window keys from autonomy_settings.
|
||||||
s.pool.Exec(ctx, `DELETE FROM autonomy_settings
|
s.pool.Exec(ctx, `DELETE FROM autonomy_settings
|
||||||
@@ -1148,9 +1252,141 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
|||||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
|
||||||
map[string]any{"status": status, "outcome": outcome, "summary": summary,
|
map[string]any{"status": status, "outcome": outcome, "summary": summary,
|
||||||
"cancelled_executions": cancelledCount, "blocker": blocker})
|
"cancelled_executions": cancelledCount, "blocker": blocker})
|
||||||
|
// Auto-persist knowledge so the graph learns from this session regardless
|
||||||
|
// of whether the agent remembered to call upsert_knowledge (2026-08-04
|
||||||
|
// session audit: only 2.4% of sessions called upsert_knowledge manually).
|
||||||
|
if outcome == "success" || outcome == "partial" {
|
||||||
|
autoUpsertKnowledge(ctx, s, sessionID, outcome, summary)
|
||||||
|
}
|
||||||
|
// Plan quality metric: compute step completion rate for the session's
|
||||||
|
// current plan generation. Tracked as a task attribute so the trend
|
||||||
|
// can be monitored over time (2026-08-04 session audit: 38% baseline).
|
||||||
|
writePlanCompletionRate(ctx, s, sessionID)
|
||||||
|
// Auto-feedback: create a feedback entry linking the session's outcome
|
||||||
|
// to its last execution, feeding the pattern-extraction pipeline that
|
||||||
|
// has been empty since launch (2026-08-04 session audit: 0 feedback rows).
|
||||||
|
if outcome == "success" || outcome == "partial" {
|
||||||
|
autoFeedback(ctx, s, sessionID, outcome, summary)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoUpsertKnowledge creates a knowledge entry for a completed session,
|
||||||
|
// capturing what was done and linking it to the entities involved. Called
|
||||||
|
// automatically from completeTask so every session leaves a trace, even if
|
||||||
|
// the agent forgot to call upsert_knowledge. Only fired for success/partial
|
||||||
|
// outcomes (failures don't have actionable discoveries).
|
||||||
|
func autoUpsertKnowledge(ctx context.Context, s *store, sessionID, outcome, summary string) {
|
||||||
|
var goal string
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`,
|
||||||
|
sessionID).Scan(&goal); err != nil || goal == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := "Session " + sessionID[:8] + ": " + goal
|
||||||
|
if len(title) > 200 {
|
||||||
|
title = title[:200]
|
||||||
|
}
|
||||||
|
content := "## Outcome\n" + outcome + "\n\n## Summary\n" + summary
|
||||||
|
kind := "investigation"
|
||||||
|
slug := "investigation:nomos/" + sessionID
|
||||||
|
tags := []string{"nomos-session", "auto-generated"}
|
||||||
|
|
||||||
|
// Upsert the knowledge entity.
|
||||||
|
docID, _ := uuid.NewV7()
|
||||||
|
if err := s.pool.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 {
|
||||||
|
slog.Warn("nomos: autoUpsertKnowledge entity insert", "session", sessionID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the knowledge content.
|
||||||
|
if _, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at)
|
||||||
|
VALUES ($1, $2, $3, 'nomos-agent', $4, now())
|
||||||
|
ON CONFLICT (entity_id) DO UPDATE
|
||||||
|
SET title = EXCLUDED.title, content = EXCLUDED.content,
|
||||||
|
tags = EXCLUDED.tags, updated_at = now()`,
|
||||||
|
docID, title, content, tags); err != nil {
|
||||||
|
slog.Warn("nomos: autoUpsertKnowledge content insert", "session", sessionID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link to the task entity.
|
||||||
|
var taskEntID uuid.UUID
|
||||||
|
if s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`,
|
||||||
|
sessionID).Scan(&taskEntID) == nil && taskEntID != uuid.Nil {
|
||||||
|
s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, $2, 'involves', '{"by":"nomos","auto":true}'::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = 'involves' AND valid_to IS NULL)`,
|
||||||
|
taskEntID, docID)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("nomos: auto-upserted knowledge for session",
|
||||||
|
"session", sessionID, "outcome", outcome, "slug", slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePlanCompletionRate computes the step completion rate for the current
|
||||||
|
// plan generation and writes it as a task entity attribute so the trend can
|
||||||
|
// be tracked. Baseline from 2026-08-04 audit: 38% (15/39 steps reached done).
|
||||||
|
func writePlanCompletionRate(ctx context.Context, s *store, sessionID string) {
|
||||||
|
var total, completed int
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END), 0)
|
||||||
|
FROM session_plan_steps
|
||||||
|
WHERE session_id = $1
|
||||||
|
AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1)
|
||||||
|
AND status <> 'replaced'`, sessionID).Scan(&total, &completed)
|
||||||
|
if total > 0 {
|
||||||
|
rate := float64(completed) / float64(total)
|
||||||
|
attrs, _ := json.Marshal(map[string]any{"plan_completion_rate": rate, "plan_steps_total": total, "plan_steps_completed": completed})
|
||||||
|
s.pool.Exec(ctx, `
|
||||||
|
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
|
||||||
|
WHERE id = (SELECT entity_id FROM agent_sessions WHERE id = $1)`,
|
||||||
|
sessionID, string(attrs))
|
||||||
|
slog.Info("nomos: plan completion rate", "session", sessionID, "rate", fmt.Sprintf("%.0f%%", rate*100),
|
||||||
|
"completed", completed, "total", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoFeedback creates a feedback entry linking the session's outcome to its
|
||||||
|
// last execution, feeding the pattern-extraction pipeline that has been empty
|
||||||
|
// since launch. Only created for success/partial outcomes (failures don't
|
||||||
|
// have a specific execution to tie to).
|
||||||
|
func autoFeedback(ctx context.Context, s *store, sessionID, outcome, summary string) {
|
||||||
|
// Find the last execution linked to this session.
|
||||||
|
var execID uuid.UUID
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT pe.execution_id FROM nomos_plan_executions pe
|
||||||
|
WHERE pe.session_id = $1::uuid
|
||||||
|
ORDER BY pe.created_at DESC LIMIT 1`, sessionID).Scan(&execID); err != nil || execID == uuid.Nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fbID, _ := uuid.NewV7()
|
||||||
|
slug := "feedback:" + fbID.String()
|
||||||
|
if _, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'feedback', $3, '{}')`,
|
||||||
|
fbID, slug, "feedback for "+sessionID[:8]); err != nil {
|
||||||
|
slog.Warn("nomos: autoFeedback entity insert", "session", sessionID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO feedback (entity_id, execution_id, outcome, observation, lesson, tags, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, now())`,
|
||||||
|
fbID, execID, outcome, summary, summary, []string{"nomos-session", "auto-generated", "session:" + sessionID[:8]})
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("nomos: autoFeedback insert", "session", sessionID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("nomos: auto-feedback created for session", "session", sessionID, "outcome", outcome)
|
||||||
|
}
|
||||||
|
|
||||||
// blockerPatterns maps a substring (case-insensitive) to a structured blocker
|
// blockerPatterns maps a substring (case-insensitive) to a structured blocker
|
||||||
// reason. Order matters — earlier patterns take precedence. These are the
|
// reason. Order matters — earlier patterns take precedence. These are the
|
||||||
// recurring failure signatures from the 2026-07-20 session audit. A
|
// recurring failure signatures from the 2026-07-20 session audit. A
|
||||||
@@ -1240,6 +1476,53 @@ func (s *store) hadDiscovery(ctx context.Context, sessionID string) bool {
|
|||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionGoal returns the session's goal text, empty string if not found.
|
||||||
|
// Used by complete_task to check whether the goal involved a reachability
|
||||||
|
// verification before marking success.
|
||||||
|
func (s *store) sessionGoal(ctx context.Context, sessionID string) string {
|
||||||
|
if s == nil || sessionID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var goal string
|
||||||
|
s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`,
|
||||||
|
sessionID).Scan(&goal)
|
||||||
|
return goal
|
||||||
|
}
|
||||||
|
|
||||||
|
// hadRecentVerification checks whether the session successfully verified
|
||||||
|
// reachability in recent turns — ping_service, or a run with curl/wget that
|
||||||
|
// returned successfully. Used by complete_task as a soft warning when the
|
||||||
|
// goal involved a reachability check but no recent verification occurred.
|
||||||
|
func (s *store) hadRecentVerification(ctx context.Context, sessionID string) bool {
|
||||||
|
if s == nil || sessionID == "" {
|
||||||
|
return true // fail safe: don't warn when we can't check
|
||||||
|
}
|
||||||
|
// Check for ping_service calls in the last 5 activity entries for this session.
|
||||||
|
var pingCount int
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT 1 FROM agent_activity
|
||||||
|
WHERE session_id = $1 AND tool_name = 'ping_service' AND success = true
|
||||||
|
ORDER BY ts DESC LIMIT 5
|
||||||
|
) sub`, sessionID).Scan(&pingCount)
|
||||||
|
if pingCount > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Check for run calls with curl/wget that returned successfully.
|
||||||
|
var curlCount int
|
||||||
|
s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT 1 FROM agent_activity
|
||||||
|
WHERE session_id = $1
|
||||||
|
AND tool_name = 'run'
|
||||||
|
AND success = true
|
||||||
|
AND (input_summary LIKE '%curl%' OR input_summary LIKE '%wget%')
|
||||||
|
ORDER BY ts DESC LIMIT 10
|
||||||
|
) sub`, sessionID).Scan(&curlCount)
|
||||||
|
return curlCount > 0
|
||||||
|
}
|
||||||
|
|
||||||
// staleGoalSession is a goal-bearing task that's gone idle without reaching
|
// staleGoalSession is a goal-bearing task that's gone idle without reaching
|
||||||
// a terminal state — the idle-sweep worker's work list (fix 2+3 of
|
// a terminal state — the idle-sweep worker's work list (fix 2+3 of
|
||||||
// plans/2026-07-11-task-completion-safety-net.md).
|
// plans/2026-07-11-task-completion-safety-net.md).
|
||||||
@@ -1347,15 +1630,23 @@ type planStep struct {
|
|||||||
|
|
||||||
// getPlanSteps returns a task's plan in order — REST hydration for the context
|
// getPlanSteps returns a task's plan in order — REST hydration for the context
|
||||||
// panel when it first opens a task (live events only carry deltas from then on).
|
// panel when it first opens a task (live events only carry deltas from then on).
|
||||||
func (s *store) getPlanSteps(ctx context.Context, sessionID string) ([]planStep, error) {
|
// By default only the CURRENT (MAX) generation is returned — the panel shows the
|
||||||
|
// live plan, not an archaeological record of every superseded generation. Pass
|
||||||
|
// all=true for the audit/eval view that needs every generation (the
|
||||||
|
// plan_generations assertion counts distinct generations across the full set).
|
||||||
|
func (s *store) getPlanSteps(ctx context.Context, sessionID string, all bool) ([]planStep, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
genFilter := ""
|
||||||
|
if !all {
|
||||||
|
genFilter = "AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1)"
|
||||||
|
}
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id::text, seq, title, detail, status,
|
SELECT id::text, seq, title, detail, status,
|
||||||
execution_id::text, target_slug,
|
execution_id::text, target_slug,
|
||||||
started_at::text, finished_at::text, generation
|
started_at::text, finished_at::text, generation
|
||||||
FROM session_plan_steps WHERE session_id = $1 ORDER BY seq`, sessionID)
|
FROM session_plan_steps WHERE session_id = $1 `+genFilter+` ORDER BY generation, seq`, sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1835,7 +2126,7 @@ func (s *store) resolveArgEntityID(ctx context.Context, args map[string]any) uui
|
|||||||
// The (nullable) session_id column carries the conversation id. args is the
|
// The (nullable) session_id column carries the conversation id. args is the
|
||||||
// tool call's own arguments, used to best-effort tag the row with the
|
// tool call's own arguments, used to best-effort tag the row with the
|
||||||
// entity it acted on (see resolveArgEntityID).
|
// entity it acted on (see resolveArgEntityID).
|
||||||
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
|
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string, tokenCount int) {
|
||||||
if s == nil || agentID == uuid.Nil {
|
if s == nil || agentID == uuid.Nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1847,8 +2138,8 @@ func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, t
|
|||||||
s.pool.Exec(ctx, `
|
s.pool.Exec(ctx, `
|
||||||
INSERT INTO agent_activity
|
INSERT INTO agent_activity
|
||||||
(agent_id, session_id, activity_type, tool_name, entity_id, input_summary, output_summary,
|
(agent_id, session_id, activity_type, tool_name, entity_id, input_summary, output_summary,
|
||||||
duration_ms, success, correlation_id)
|
duration_ms, success, correlation_id, token_count)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
|
agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
|
||||||
durationMs, success, correlationID)
|
durationMs, success, correlationID, tokenCount)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -245,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
|
||||||
}
|
}
|
||||||
@@ -280,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":
|
||||||
@@ -291,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
|
||||||
@@ -349,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
|
||||||
@@ -365,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
152
cmd/nomos/workers.go
Normal file
152
cmd/nomos/workers.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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) {})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
|
"github.com/dtoro/oikos/internal/execworker"
|
||||||
"github.com/dtoro/oikos/internal/httpapi"
|
"github.com/dtoro/oikos/internal/httpapi"
|
||||||
"github.com/dtoro/oikos/internal/knowledge"
|
"github.com/dtoro/oikos/internal/knowledge"
|
||||||
"github.com/dtoro/oikos/internal/notifier"
|
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/dtoro/oikos/internal/scheduler"
|
"github.com/dtoro/oikos/internal/scheduler"
|
||||||
"github.com/dtoro/oikos/internal/secrets"
|
"github.com/dtoro/oikos/internal/secrets"
|
||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var schedulerRunner = scheduler.RunnerForMain()
|
var schedulerRunner = scheduler.RunnerForMain()
|
||||||
var notifierRunner = notifier.RunnerForMain()
|
var execWorkerRunner = execworker.RunnerForMain()
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
@@ -37,12 +37,37 @@ func main() {
|
|||||||
logger := observability.NewLogger(cfg.Debug)
|
logger := observability.NewLogger(cfg.Debug)
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
slog.Info("starting oikos", "role", role, "config", cfg)
|
|
||||||
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(),
|
ctx, cancel := signal.NotifyContext(context.Background(),
|
||||||
syscall.SIGTERM, syscall.SIGINT)
|
syscall.SIGTERM, syscall.SIGINT)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Resolve secrets from Infisical, overlaying env-derived config values.
|
||||||
|
// If Infisical is not configured, env vars are used as-is (no change).
|
||||||
|
sec := secrets.NewManagerFromConfig(
|
||||||
|
cfg.InfisicalSiteURL,
|
||||||
|
cfg.InfisicalClientID,
|
||||||
|
cfg.InfisicalClientSecret,
|
||||||
|
cfg.InfisicalProjectID,
|
||||||
|
cfg.InfisicalEnv,
|
||||||
|
cfg.SecretsDir,
|
||||||
|
)
|
||||||
|
if sec != nil {
|
||||||
|
overlays := secrets.ConfigOverlays(map[string]func(string){
|
||||||
|
"mcp_bearer-token": func(v string) { cfg.MCPBearerToken = v },
|
||||||
|
"api_token": func(v string) { cfg.APIToken = v },
|
||||||
|
"oidc_client-secret": func(v string) { cfg.OIDCClientSecret = v },
|
||||||
|
})
|
||||||
|
n := secrets.OverlayConfig(ctx, sec, overlays)
|
||||||
|
slog.Info("secrets resolved from Infisical", "count", n)
|
||||||
|
|
||||||
|
secrets.VerifyExpectedSecrets(ctx, sec, []string{
|
||||||
|
"approval_hmac-secret", "mcp_bearer-token",
|
||||||
|
"api_token", "openrouter_api-key", "webhook_hmac-secret",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("starting oikos", "role", role, "config", cfg)
|
||||||
|
|
||||||
switch role {
|
switch role {
|
||||||
case "migrate":
|
case "migrate":
|
||||||
if err := runMigrate(ctx, cfg); err != nil {
|
if err := runMigrate(ctx, cfg); err != nil {
|
||||||
@@ -66,8 +91,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
case "scheduler":
|
case "scheduler":
|
||||||
runWithPool(ctx, cfg, "scheduler", schedulerRunner)
|
runWithPool(ctx, cfg, "scheduler", schedulerRunner)
|
||||||
case "notifier":
|
case "execution-worker":
|
||||||
runWithPool(ctx, cfg, "notifier", notifierRunner)
|
runWithPool(ctx, cfg, "execution-worker", execWorkerRunner)
|
||||||
case "all":
|
case "all":
|
||||||
pool, err := db.New(ctx, cfg.DatabaseURL)
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,9 +107,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
go schedulerRunner(ctx, pool, cfg)
|
go schedulerRunner(ctx, pool, cfg)
|
||||||
go notifierRunner(ctx, pool, cfg)
|
go execWorkerRunner(ctx, pool, cfg)
|
||||||
|
|
||||||
slog.Info("all: starting api with scheduler + notifier in background")
|
slog.Info("all: starting api with scheduler + execution-worker in background")
|
||||||
if err := httpapi.ListenAndServe(ctx, pool, cfg); err != nil {
|
if err := httpapi.ListenAndServe(ctx, pool, cfg); err != nil {
|
||||||
slog.Error("api failed", "error", err)
|
slog.Error("api failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -113,9 +138,8 @@ Roles:
|
|||||||
export Export DB state back to seed YAMLs (DR / version control)
|
export Export DB state back to seed YAMLs (DR / version control)
|
||||||
api Run the REST + MCP API server
|
api Run the REST + MCP API server
|
||||||
scheduler Run the observe loop
|
scheduler Run the observe loop
|
||||||
notifier Run the notification service (Matrix alerts)
|
|
||||||
all Run all roles in one process (dev mode)
|
all Run all roles in one process (dev mode)
|
||||||
secret Secret management (Infisical)
|
secret Secret management (Infisical: get, set, list, verify, audit, migrate, export-sops)
|
||||||
knowledge Convert wiki to knowledge seed (one-shot)
|
knowledge Convert wiki to knowledge seed (one-shot)
|
||||||
version Print version info
|
version Print version info
|
||||||
|
|
||||||
@@ -289,20 +313,45 @@ func runWithPool(ctx context.Context, cfg config.Config, name string, fn func(co
|
|||||||
|
|
||||||
func runSecret(ctx context.Context, cfg config.Config) {
|
func runSecret(ctx context.Context, cfg config.Config) {
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
fmt.Fprintln(os.Stderr, "usage: oikos secret <list|migrate|export-sops>")
|
fmt.Fprintln(os.Stderr, "usage: oikos secret <get|set|list|migrate|export-sops>")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
sub := os.Args[2]
|
sub := os.Args[2]
|
||||||
secretsDir := cfg.SecretsDir
|
|
||||||
if secretsDir == "" {
|
|
||||||
secretsDir = "archive/secrets-sops-backup"
|
|
||||||
}
|
|
||||||
sopsBackend := secrets.NewSOPSBackend(secretsDir)
|
|
||||||
|
|
||||||
|
// For get/set/list: use Infisical directly
|
||||||
switch sub {
|
switch sub {
|
||||||
|
case "get":
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: oikos secret get <key>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
key := os.Args[3]
|
||||||
|
backend := newInfisicalBackendOrFail(cfg)
|
||||||
|
val, err := backend.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("secret get", "key", key, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(val)
|
||||||
|
|
||||||
|
case "set":
|
||||||
|
if len(os.Args) < 5 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: oikos secret set <key> <value>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
key := os.Args[3]
|
||||||
|
value := os.Args[4]
|
||||||
|
backend := newInfisicalBackendOrFail(cfg)
|
||||||
|
if err := backend.Set(ctx, key, value); err != nil {
|
||||||
|
slog.Error("secret set", "key", key, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("stored: %s\n", key)
|
||||||
|
|
||||||
case "list":
|
case "list":
|
||||||
keys, err := sopsBackend.List(ctx)
|
backend := newInfisicalBackendOrFail(cfg)
|
||||||
|
keys, err := backend.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("secret list", "error", err)
|
slog.Error("secret list", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -311,24 +360,134 @@ func runSecret(ctx context.Context, cfg config.Config) {
|
|||||||
fmt.Println(k)
|
fmt.Println(k)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "migrate":
|
case "verify":
|
||||||
infCfg := secrets.InfisicalConfig{
|
runSecretVerify(ctx, cfg)
|
||||||
SiteURL: cfg.InfisicalSiteURL,
|
|
||||||
ClientID: cfg.InfisicalClientID,
|
|
||||||
ClientSecret: cfg.InfisicalClientSecret,
|
|
||||||
ProjectID: cfg.InfisicalProjectID,
|
|
||||||
SecretPath: "/",
|
|
||||||
Env: cfg.InfisicalEnv,
|
|
||||||
}
|
|
||||||
if infCfg.Env == "" {
|
|
||||||
infCfg.Env = "dev"
|
|
||||||
}
|
|
||||||
if infCfg.SiteURL == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "error: OIKOS_INFISICAL_SITE_URL not set")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
infBackend := secrets.NewInfisicalBackend(infCfg)
|
case "audit":
|
||||||
|
runSecretAudit(ctx, cfg)
|
||||||
|
|
||||||
|
case "migrate", "export-sops":
|
||||||
|
runSecretLegacy(ctx, cfg, sub)
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown secret command: %s\n", sub)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expectedSecrets is the set of keys that should exist in Infisical
|
||||||
|
// for a fully-migrated deployment.
|
||||||
|
var expectedSecrets = []string{
|
||||||
|
"mcp_bearer-token",
|
||||||
|
"api_token",
|
||||||
|
"openrouter_api-key",
|
||||||
|
"webhook_hmac-secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSecretVerify checks that all expected secrets are present in Infisical.
|
||||||
|
func runSecretVerify(ctx context.Context, cfg config.Config) {
|
||||||
|
backend := newInfisicalBackendOrFail(cfg)
|
||||||
|
keys, err := backend.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("verify: list", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
keySet := make(map[string]struct{}, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
keySet[k] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := 0
|
||||||
|
for _, exp := range expectedSecrets {
|
||||||
|
if _, ok := keySet[exp]; !ok {
|
||||||
|
fmt.Printf("MISSING: %s\n", exp)
|
||||||
|
missing++
|
||||||
|
} else {
|
||||||
|
fmt.Printf("OK: %s\n", exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n%d/%d present, %d missing\n", len(expectedSecrets)-missing, len(expectedSecrets), missing)
|
||||||
|
if missing > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSecretAudit resolves all expected secrets from Infisical and prints
|
||||||
|
// a diff against current env-derived values. Values are truncated for safety.
|
||||||
|
func runSecretAudit(ctx context.Context, cfg config.Config) {
|
||||||
|
backend := newInfisicalBackendOrFail(cfg)
|
||||||
|
|
||||||
|
envValues := map[string]string{
|
||||||
|
"mcp_bearer-token": cfg.MCPBearerToken,
|
||||||
|
"api_token": cfg.APIToken,
|
||||||
|
"oidc_client-secret": cfg.OIDCClientSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("key infisical env status")
|
||||||
|
fmt.Println(strings.Repeat("-", 72))
|
||||||
|
|
||||||
|
for _, key := range expectedSecrets {
|
||||||
|
infVal, infErr := backend.Get(ctx, key)
|
||||||
|
envVal := envValues[key]
|
||||||
|
|
||||||
|
if infErr != nil {
|
||||||
|
fmt.Printf("%-29s ERROR %-10s NOT-IN-INFISICAL\n", key, trunc(envVal, 8))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if envVal == "" {
|
||||||
|
fmt.Printf("%-29s %-10s (empty) INFISICAL-ONLY\n", key, trunc(infVal, 8))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if infVal == envVal {
|
||||||
|
fmt.Printf("%-29s %-10s %-10s MATCH\n", key, trunc(infVal, 8), trunc(envVal, 8))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%-29s %-10s %-10s DRIFT\n", key, trunc(infVal, 8), trunc(envVal, 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trunc(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if n > 1 {
|
||||||
|
return s[:n-1] + "…"
|
||||||
|
}
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
// newInfisicalBackendOrFail creates an Infisical backend from config or exits.
|
||||||
|
func newInfisicalBackendOrFail(cfg config.Config) *secrets.InfisicalBackend {
|
||||||
|
if cfg.InfisicalSiteURL == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: OIKOS_INFISICAL_SITE_URL not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
infCfg := secrets.InfisicalConfig{
|
||||||
|
SiteURL: cfg.InfisicalSiteURL,
|
||||||
|
ClientID: cfg.InfisicalClientID,
|
||||||
|
ClientSecret: cfg.InfisicalClientSecret,
|
||||||
|
ProjectID: cfg.InfisicalProjectID,
|
||||||
|
SecretPath: "/",
|
||||||
|
Env: cfg.InfisicalEnv,
|
||||||
|
}
|
||||||
|
if infCfg.Env == "" {
|
||||||
|
infCfg.Env = "dev"
|
||||||
|
}
|
||||||
|
return secrets.NewInfisicalBackend(infCfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSecretLegacy handles SOPS-only commands (migrate, export-sops).
|
||||||
|
func runSecretLegacy(ctx context.Context, cfg config.Config, sub string) {
|
||||||
|
secretsDir := cfg.SecretsDir
|
||||||
|
if secretsDir == "" {
|
||||||
|
secretsDir = "archive/secrets-sops-backup"
|
||||||
|
}
|
||||||
|
sopsBackend := secrets.NewSOPSBackend(secretsDir)
|
||||||
|
|
||||||
|
switch sub {
|
||||||
|
case "migrate":
|
||||||
|
infBackend := newInfisicalBackendOrFail(cfg)
|
||||||
keys, err := sopsBackend.List(ctx)
|
keys, err := sopsBackend.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("migrate: read sops", "error", err)
|
slog.Error("migrate: read sops", "error", err)
|
||||||
@@ -366,10 +525,6 @@ func runSecret(ctx context.Context, cfg config.Config) {
|
|||||||
fmt.Printf("%s: <sops-encrypted>\n", k)
|
fmt.Printf("%s: <sops-encrypted>\n", k)
|
||||||
}
|
}
|
||||||
fmt.Printf("\n# To restore: sops -d secrets/*.yaml\n")
|
fmt.Printf("\n# To restore: sops -d secrets/*.yaml\n")
|
||||||
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(os.Stderr, "unknown secret command: %s\n", sub)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -12,26 +13,31 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/secrets"
|
||||||
"github.com/dtoro/oikos/internal/safego"
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
port := os.Getenv("WEBHOOK_LISTEN")
|
port := os.Getenv("WEBHOOK_LISTEN")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = ":9797"
|
port = ":9797"
|
||||||
}
|
}
|
||||||
|
|
||||||
secret := os.Getenv("WEBHOOK_HMAC_SECRET")
|
|
||||||
if secret == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "WEBHOOK_HMAC_SECRET must be set")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
repoDir := os.Getenv("WEBHOOK_REPO_DIR")
|
repoDir := os.Getenv("WEBHOOK_REPO_DIR")
|
||||||
if repoDir == "" {
|
if repoDir == "" {
|
||||||
repoDir = os.Getenv("HOME") + "/Projects/oikos"
|
repoDir = os.Getenv("HOME") + "/Projects/oikos"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create secrets manager once, share between HMAC resolution and deploy
|
||||||
|
sec := newSecrets()
|
||||||
|
secret := resolveWebhookHMAC(ctx, sec)
|
||||||
|
if secret == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "WEBHOOK_HMAC_SECRET must be set (env var or Infisical webhook_hmac-secret)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/deploy", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/deploy", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -66,11 +72,16 @@ func main() {
|
|||||||
w.Write([]byte(`{"status":"deploy started"}`))
|
w.Write([]byte(`{"status":"deploy started"}`))
|
||||||
|
|
||||||
safego.Go("webhook:deploy", func() {
|
safego.Go("webhook:deploy", func() {
|
||||||
|
apiToken := ""
|
||||||
|
if sec != nil {
|
||||||
|
apiToken = secrets.ResolveSecret(ctx, sec, "api_token", "")
|
||||||
|
}
|
||||||
cmd := exec.Command(repoDir + "/scripts/deploy.sh")
|
cmd := exec.Command(repoDir + "/scripts/deploy.sh")
|
||||||
cmd.Dir = repoDir
|
cmd.Dir = repoDir
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
"REPO_DIR="+repoDir,
|
"REPO_DIR="+repoDir,
|
||||||
"PROFILE=full",
|
"PROFILE=full",
|
||||||
|
"OIKOS_API_TOKEN="+apiToken,
|
||||||
)
|
)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
@@ -94,3 +105,25 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newSecrets creates the Infisical secrets manager from env vars.
|
||||||
|
func newSecrets() *secrets.Manager {
|
||||||
|
return secrets.NewManagerFromConfig(
|
||||||
|
os.Getenv("OIKOS_INFISICAL_SITE_URL"),
|
||||||
|
os.Getenv("OIKOS_INFISICAL_CLIENT_ID"),
|
||||||
|
os.Getenv("OIKOS_INFISICAL_CLIENT_SECRET"),
|
||||||
|
os.Getenv("OIKOS_INFISICAL_PROJECT_ID"),
|
||||||
|
os.Getenv("OIKOS_INFISICAL_ENV"),
|
||||||
|
os.Getenv("OIKOS_SECRETS_DIR"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveWebhookHMAC fetches the webhook HMAC secret from Infisical,
|
||||||
|
// falling back to the WEBHOOK_HMAC_SECRET env var.
|
||||||
|
func resolveWebhookHMAC(ctx context.Context, sec *secrets.Manager) string {
|
||||||
|
envFallback := os.Getenv("WEBHOOK_HMAC_SECRET")
|
||||||
|
if sec == nil {
|
||||||
|
return envFallback
|
||||||
|
}
|
||||||
|
return secrets.ResolveSecret(ctx, sec, "webhook_hmac-secret", envFallback)
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
:80 {
|
|
||||||
root * /srv
|
|
||||||
file_server
|
|
||||||
try_files {path} /index.html
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Dockerfile for the oikos control-room SPA. Built separately from the
|
|
||||||
# oikos binary (compose/oikos/Dockerfile) — see docker-compose.yml's `web`
|
|
||||||
# service. The outer production Caddy (caddy-conf repo, LXC 121) handles
|
|
||||||
# Authentik + splits /api/*, /mcp, /agent/* off to the api service; this
|
|
||||||
# container only serves static files with SPA-fallback routing.
|
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /build/web
|
|
||||||
COPY web/package.json web/package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY VERSION ./
|
|
||||||
COPY web/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM caddy:2-alpine
|
|
||||||
|
|
||||||
COPY --from=builder /build/web/dist /srv
|
|
||||||
COPY compose/web/Caddyfile /etc/caddy/Caddyfile
|
|
||||||
@@ -20,6 +20,8 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pg-data:/var/lib/postgresql/data
|
- pg-data:/var/lib/postgresql/data
|
||||||
|
mem_limit: 1g
|
||||||
|
cpus: 2.0
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "pg_isready", "-U", "oikos"]
|
test: ["CMD", "pg_isready", "-U", "oikos"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -28,6 +30,7 @@ services:
|
|||||||
|
|
||||||
# One-shot: run migrations then exit
|
# One-shot: run migrations then exit
|
||||||
migrate:
|
migrate:
|
||||||
|
image: oikos-migrate:${OIKOS_VERSION:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
@@ -38,9 +41,12 @@ services:
|
|||||||
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
||||||
command: ["migrate"]
|
command: ["migrate"]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
|
||||||
# One-shot: ingest seeds then exit
|
# One-shot: ingest seeds then exit
|
||||||
seed:
|
seed:
|
||||||
|
image: oikos-seed:${OIKOS_VERSION:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
@@ -52,9 +58,12 @@ services:
|
|||||||
OIKOS_SEEDS_DIR: /seeds
|
OIKOS_SEEDS_DIR: /seeds
|
||||||
command: ["seed"]
|
command: ["seed"]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
|
||||||
# API server (Phase 2)
|
# API server (Phase 2)
|
||||||
api:
|
api:
|
||||||
|
image: oikos-api:${OIKOS_VERSION:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
@@ -76,6 +85,16 @@ services:
|
|||||||
OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod}
|
OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod}
|
||||||
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
|
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
|
||||||
NOMOS_PROXY_URL: http://nomos:8092
|
NOMOS_PROXY_URL: http://nomos:8092
|
||||||
|
# Rate limiting (plan D3). Default off; set OIKOS_API_RATE_LIMIT to a
|
||||||
|
# requests/sec value to throttle runaway agent loops per source IP.
|
||||||
|
OIKOS_API_RATE_LIMIT: ${OIKOS_API_RATE_LIMIT:-}
|
||||||
|
OIKOS_API_RATE_BURST: ${OIKOS_API_RATE_BURST:-}
|
||||||
|
# Infisical secret store (Phase 5)
|
||||||
|
OIKOS_INFISICAL_SITE_URL: ${OIKOS_INFISICAL_SITE_URL:-}
|
||||||
|
OIKOS_INFISICAL_CLIENT_ID: ${OIKOS_INFISICAL_CLIENT_ID:-}
|
||||||
|
OIKOS_INFISICAL_CLIENT_SECRET: ${OIKOS_INFISICAL_CLIENT_SECRET:-}
|
||||||
|
OIKOS_INFISICAL_PROJECT_ID: ${OIKOS_INFISICAL_PROJECT_ID:-}
|
||||||
|
OIKOS_INFISICAL_ENV: ${OIKOS_INFISICAL_ENV:-dev}
|
||||||
volumes:
|
volumes:
|
||||||
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
||||||
ports:
|
ports:
|
||||||
@@ -83,9 +102,28 @@ services:
|
|||||||
command: ["api"]
|
command: ["api"]
|
||||||
stop_signal: SIGTERM
|
stop_signal: SIGTERM
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
# 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.
|
||||||
|
# The api's NewHandler stalls on TWO unreachable external deps at startup
|
||||||
|
# before binding :8090: Infisical (4x auth retries, ~40s) and OIDC
|
||||||
|
# discovery (auth.hubris.network, ~35s of timeouts). Total ~90-95s, so
|
||||||
|
# the start period must clear it or nomos (depends_on: api-healthy) fails.
|
||||||
|
start_period: 180s
|
||||||
|
|
||||||
# Scheduler (Phase 3) — observe loop
|
# Scheduler (Phase 3) — observe loop
|
||||||
scheduler:
|
scheduler:
|
||||||
|
image: oikos-scheduler:${OIKOS_VERSION:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
@@ -100,6 +138,9 @@ services:
|
|||||||
OIKOS_SCHEDULER_INTERVAL: "30s"
|
OIKOS_SCHEDULER_INTERVAL: "30s"
|
||||||
OIKOS_SSH_KEY_PATH: /etc/oikos/ssh_key
|
OIKOS_SSH_KEY_PATH: /etc/oikos/ssh_key
|
||||||
OIKOS_SSH_USER: root
|
OIKOS_SSH_USER: root
|
||||||
|
# Liveness probe (plan D5): exposes a staleness-aware /healthz inside
|
||||||
|
# the container; the scheduler bumps it each check pass.
|
||||||
|
OIKOS_HEALTH_LISTEN: ":8093"
|
||||||
volumes:
|
volumes:
|
||||||
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -107,9 +148,18 @@ services:
|
|||||||
command: ["scheduler"]
|
command: ["scheduler"]
|
||||||
stop_signal: SIGTERM
|
stop_signal: SIGTERM
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
|
mem_limit: 256m
|
||||||
|
cpus: 1.0
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8093/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 90s
|
||||||
|
|
||||||
# Notifier (Phase 3) — Matrix alerts
|
# Execution worker (Phase 6) — Postgres-backed job queue
|
||||||
notifier:
|
execution-worker:
|
||||||
|
image: oikos-execution-worker:${OIKOS_VERSION:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
@@ -121,17 +171,26 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
||||||
OIKOS_DEBUG: "true"
|
OIKOS_DEBUG: "true"
|
||||||
OIKOS_APPROVAL_HMAC_SECRET: ${OIKOS_APPROVAL_HMAC_SECRET:-dev-secret}
|
OIKOS_SSH_KEY_PATH: /etc/oikos/ssh_key
|
||||||
OIKOS_MATRIX_HOMESERVER: ${OIKOS_MATRIX_HOMESERVER:-https://matrix.hubris.network}
|
OIKOS_SSH_USER: root
|
||||||
OIKOS_MATRIX_USER: ${OIKOS_MATRIX_USER:-@hermes:hubris.network}
|
OIKOS_HEALTH_LISTEN: ":8095"
|
||||||
OIKOS_MATRIX_TOKEN: ${OIKOS_MATRIX_TOKEN}
|
volumes:
|
||||||
OIKOS_MATRIX_ROOM: ${OIKOS_MATRIX_ROOM:-!alerts:hubris.network}
|
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
||||||
command: ["notifier"]
|
command: ["execution-worker"]
|
||||||
stop_signal: SIGTERM
|
stop_signal: SIGTERM
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
|
mem_limit: 256m
|
||||||
|
cpus: 1.0
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8095/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 90s
|
||||||
|
|
||||||
# Nomos agent gateway (Phase 4) — mesh-published :8092
|
# Nomos agent gateway (Phase 4) — mesh-published :8092
|
||||||
nomos:
|
nomos:
|
||||||
|
image: oikos-nomos:${OIKOS_VERSION:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/nomos/Dockerfile
|
dockerfile: compose/nomos/Dockerfile
|
||||||
@@ -139,7 +198,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
|
||||||
@@ -149,24 +213,32 @@ services:
|
|||||||
# Must match api's OIKOS_MCP_BEARER_TOKEN above — api's combinedAuth
|
# Must match api's OIKOS_MCP_BEARER_TOKEN above — api's combinedAuth
|
||||||
# rejects every request without it now (no dev-open bypass).
|
# rejects every request without it now (no dev-open bypass).
|
||||||
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
|
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
|
||||||
|
# Infisical secret store (Phase 5) — nomos resolves mcp_bearer-token
|
||||||
|
# and openrouter_api-key from here, overriding the env values above.
|
||||||
|
OIKOS_INFISICAL_SITE_URL: ${OIKOS_INFISICAL_SITE_URL:-}
|
||||||
|
OIKOS_INFISICAL_CLIENT_ID: ${OIKOS_INFISICAL_CLIENT_ID:-}
|
||||||
|
OIKOS_INFISICAL_CLIENT_SECRET: ${OIKOS_INFISICAL_CLIENT_SECRET:-}
|
||||||
|
OIKOS_INFISICAL_PROJECT_ID: ${OIKOS_INFISICAL_PROJECT_ID:-}
|
||||||
|
OIKOS_INFISICAL_ENV: ${OIKOS_INFISICAL_ENV:-dev}
|
||||||
ports:
|
ports:
|
||||||
- "8092:8092"
|
- "8092:8092"
|
||||||
stop_signal: SIGTERM
|
stop_signal: SIGTERM
|
||||||
stop_grace_period: 10s
|
stop_grace_period: 10s
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
# nomos runs on a distroless image (no shell/wget), so the healthcheck
|
||||||
|
# uses the binary's own `healthcheck` subcommand to self-probe /healthz.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/nomos", "healthcheck"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
# Control-room SPA — static build served behind Caddy. The outer
|
# The control-room SPA moved to its own repo (dtoro/oikos-web, Phase 1 of
|
||||||
# production Caddy (caddy-conf repo, LXC 121) splits /api/*, /mcp,
|
# plans/2026-08-15-hexagonal-architecture.md) with its own compose project
|
||||||
# /agent/* off to api:8090 and sends everything else here; this
|
# publishing the same host port 8091 — the outer Caddy targets the published
|
||||||
# container only serves static files with SPA-fallback routing.
|
# port, so nothing here changes for routing.
|
||||||
web:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: compose/web/Dockerfile
|
|
||||||
restart: unless-stopped
|
|
||||||
profiles: ["dev", "full"]
|
|
||||||
ports:
|
|
||||||
- "8091:80"
|
|
||||||
stop_signal: SIGTERM
|
|
||||||
|
|
||||||
# Redis (required by Infisical — Phase 5)
|
# Redis (required by Infisical — Phase 5)
|
||||||
redis:
|
redis:
|
||||||
@@ -175,6 +247,8 @@ services:
|
|||||||
profiles: ["infisical", "full"]
|
profiles: ["infisical", "full"]
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
|
mem_limit: 128m
|
||||||
|
cpus: 0.5
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -183,7 +257,7 @@ services:
|
|||||||
|
|
||||||
# Infisical self-hosted (Phase 5 secrets management)
|
# Infisical self-hosted (Phase 5 secrets management)
|
||||||
infisical:
|
infisical:
|
||||||
image: infisical/infisical:latest
|
image: infisical/infisical:v0.162.19
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: ["infisical", "full"]
|
profiles: ["infisical", "full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -205,6 +279,8 @@ services:
|
|||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pg-data:
|
pg-data:
|
||||||
|
|||||||
@@ -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.
|
||||||
69
docs/adr/0016-hexagonal-ports-adapters.md
Normal file
69
docs/adr/0016-hexagonal-ports-adapters.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# ADR 0016 — Hexagonal (ports & adapters) architecture for the oikos backend
|
||||||
|
|
||||||
|
Status: accepted (2026-08-15) · Plan: plans/2026-08-15-hexagonal-architecture.md
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The oikos backend grew as delivery-layer packages with business logic inside
|
||||||
|
them. `internal/httpapi` (~3500 LOC) and `internal/mcp` (~2100 LOC, 67 tools)
|
||||||
|
each embed raw SQL next to validation, policy, and audit writes — two parallel
|
||||||
|
silos re-implementing the same use-cases. The scheduler is a ~1100-LOC
|
||||||
|
monolith mixing probe dispatch, health aggregation, signal transitions, and
|
||||||
|
metric writes. There is one accidental port/adapter pair
|
||||||
|
(`secrets.Backend`) and one inverted dependency (`internal/db` imports
|
||||||
|
`checkdefaults`). Use-cases have no home: every new consumer (REST handler,
|
||||||
|
MCP tool, scheduler pass) copy-pastes query + policy + audit logic, and
|
||||||
|
behavior drifts between paths that must agree — the run/approve/execute
|
||||||
|
lifecycle exists in three variants.
|
||||||
|
|
||||||
|
A staff-level review of the refactor plan settled the open questions: one
|
||||||
|
hexagon (not per-binary), nomos stays an external agent client over the wire,
|
||||||
|
aggregate-scoped repository methods instead of a UnitOfWork port, reads bypass
|
||||||
|
services via a shared `ReadModels` port, and the client (web SPA + desktop)
|
||||||
|
moves to its own repository before the backend churn starts.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Adopt ports & adapters across the backend, delivered in ten shippable phases:
|
||||||
|
|
||||||
|
- `internal/core/domain` (pure model, stdlib only), `internal/core/app`
|
||||||
|
(application services = use-cases), `internal/core/ports` (driven-port
|
||||||
|
interfaces). Core imports nothing from adapters; adapters and `cmd/*`
|
||||||
|
import core.
|
||||||
|
- All I/O behind named ports implemented by adapters under
|
||||||
|
`internal/adapters/`: postgres (repositories), ssh (CommandExecutor),
|
||||||
|
probes/* (one Checker per check kind), remote (TargetResolver), secrets,
|
||||||
|
events (EventPublisher/SSE), plus the driving adapters httpapi, mcpserver,
|
||||||
|
scheduler, execworker, cli.
|
||||||
|
- One repository method = one transaction = one aggregate's atomic boundary
|
||||||
|
(`§3.6` of the plan). Inputs carry audit/event entries and derived checks;
|
||||||
|
no `pgx.Tx` crosses into core; no UnitOfWork port.
|
||||||
|
- Commands flow through `core/app` services; invariant-free reads flow
|
||||||
|
adapter → `ReadModels` → presenter with no service hop (CQRS-lite).
|
||||||
|
- REST and MCP keep their existing wire formats and become thin driving
|
||||||
|
adapters over the same services; presenters stay per-adapter.
|
||||||
|
- nomos never links core: it consumes oikos exclusively through MCP/REST.
|
||||||
|
Its Phase 8 cleanup defines nomos-local ports (`LLMClient`, `HomelabClient`,
|
||||||
|
`SessionStore`) outside `core/ports`.
|
||||||
|
- The Gitea `webhook` receiver and the desktop shell are leaf utilities —
|
||||||
|
documented, not restructured.
|
||||||
|
- Dependency rules are enforced with `depguard` from Phase 0 (rule 3, the
|
||||||
|
`internal/nomos` bans, activates when that package exists in Phase 8).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- No API, MCP-tool, DB-schema, or wire-format changes ride along; behavior
|
||||||
|
parity is guarded by the existing handler contract tests plus a repository
|
||||||
|
conformance suite asserting atomicity and check-then-act per command method.
|
||||||
|
- `internal/db` shrinks to connection/migrations/sqlcgen inside the postgres
|
||||||
|
adapter; the `db → checkdefaults` inversion disappears with `SeedService`.
|
||||||
|
- Each phase merges green (`make lint test generate-check`, `make test-db`
|
||||||
|
where repositories change) with a patch version bump; per-phase abort
|
||||||
|
criteria revert a merge that breaks transaction semantics. Minor bump at
|
||||||
|
Phase 9 completion.
|
||||||
|
- Cost accepted: ~10 phases of import churn, sqlc path updates under
|
||||||
|
`make generate-check`, and a temporary period where old and new package
|
||||||
|
locations coexist (depguard denies core→adapter imports from Phase 0 so
|
||||||
|
the new tree can never grow the old inversions).
|
||||||
|
- Risk trade-off recorded in the plan: a repository method whose tx span is
|
||||||
|
too narrow is a bug class this design makes possible; the conformance
|
||||||
|
suite is the mitigation, and the check-then-act sites enumerated in the
|
||||||
|
plan (approval decide, entity transition, check derivation, execution
|
||||||
|
claim) are its first assertions.
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Oikos — Desktop Mascot Subsystem Model
|
# Oikos — Desktop Mascot Subsystem Model
|
||||||
|
|
||||||
|
> **Path note (2026-08-15):** the `web/src/...` paths and relative links
|
||||||
|
> below predate the Phase 1 client extraction — that tree now lives in the
|
||||||
|
> `dtoro/oikos-web` repo. Read them as `web/src/...` under that checkout.
|
||||||
|
|
||||||
> Companion to [the platform Model](../mbse/README.md) and
|
> Companion to [the platform Model](../mbse/README.md) and
|
||||||
> [the Framework](../mbse/framework.md). This document is a **subsystem
|
> [the Framework](../mbse/framework.md). This document is a **subsystem
|
||||||
> Model** in Holt's sense — it conforms to the same Framework (Ontology +
|
> Model** in Holt's sense — it conforms to the same Framework (Ontology +
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Oikos — System Model
|
# Oikos — System Model
|
||||||
|
|
||||||
|
> **Path note (2026-08-15):** `web/`, `cmd/desktop`, and
|
||||||
|
> `compose/web/Dockerfile` references below moved to the `dtoro/oikos-web`
|
||||||
|
> repo in the Phase 1 client extraction
|
||||||
|
> (plans/2026-08-15-hexagonal-architecture.md).
|
||||||
|
|
||||||
A single Model-Based Systems Engineering (MBSE) view of Oikos, structured
|
A single Model-Based Systems Engineering (MBSE) view of Oikos, structured
|
||||||
after *Systems Engineering Demystified* (2nd ed., Jon Holt): one underlying
|
after *Systems Engineering Demystified* (2nd ed., Jon Holt): one underlying
|
||||||
system — the Oikos entity graph, its OODA control loop, and the services
|
system — the Oikos entity graph, its OODA control loop, and the services
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Oikos — Component Views
|
# Oikos — Component Views
|
||||||
|
|
||||||
|
> **Path note (2026-08-15):** `web/src/...` and `cmd/desktop` references
|
||||||
|
> below moved to the `dtoro/oikos-web` repo in the Phase 1 client
|
||||||
|
> extraction (plans/2026-08-15-hexagonal-architecture.md).
|
||||||
|
|
||||||
> Companion to [the Model](README.md) and [the Framework](framework.md).
|
> Companion to [the Model](README.md) and [the Framework](framework.md).
|
||||||
> Where README.md's nine Views cut across the whole system by *concern*
|
> Where README.md's nine Views cut across the whole system by *concern*
|
||||||
> (requirements, behavior, risk...), this document cuts across it by
|
> (requirements, behavior, risk...), this document cuts across it by
|
||||||
|
|||||||
13
go.mod
13
go.mod
@@ -14,11 +14,10 @@ require (
|
|||||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||||
github.com/oapi-codegen/runtime v1.4.2
|
github.com/oapi-codegen/runtime v1.4.2
|
||||||
github.com/openai/openai-go v1.12.0
|
github.com/openai/openai-go v1.12.0
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
|
||||||
github.com/zalando/go-keyring v0.2.8
|
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/sync v0.21.0
|
golang.org/x/sync v0.21.0
|
||||||
golang.org/x/sys v0.46.0
|
golang.org/x/sys v0.46.0
|
||||||
|
golang.org/x/time v0.14.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ require (
|
|||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
cloud.google.com/go/iam v1.1.11 // indirect
|
cloud.google.com/go/iam v1.1.11 // indirect
|
||||||
github.com/adrg/xdg v0.5.3 // indirect
|
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect
|
github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect
|
github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect
|
||||||
@@ -43,16 +41,13 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect
|
||||||
github.com/aws/smithy-go v1.20.2 // indirect
|
github.com/aws/smithy-go v1.20.2 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
|
||||||
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||||
github.com/go-resty/resty/v2 v2.13.1 // indirect
|
github.com/go-resty/resty/v2 v2.13.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
|
||||||
github.com/gofrs/flock v0.8.1 // indirect
|
github.com/gofrs/flock v0.8.1 // indirect
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||||
@@ -61,9 +56,6 @@ require (
|
|||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/oasdiff/yaml v0.1.0 // indirect
|
github.com/oasdiff/yaml v0.1.0 // indirect
|
||||||
github.com/oasdiff/yaml3 v0.0.13 // indirect
|
github.com/oasdiff/yaml3 v0.0.13 // indirect
|
||||||
github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect
|
github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect
|
||||||
@@ -88,7 +80,6 @@ require (
|
|||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
golang.org/x/oauth2 v0.35.0 // indirect
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
|
||||||
google.golang.org/api v0.267.0 // indirect
|
google.golang.org/api v0.267.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||||
|
|||||||
29
go.sum
29
go.sum
@@ -7,8 +7,6 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB
|
|||||||
cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw=
|
cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw=
|
||||||
cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ=
|
cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ=
|
||||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
|
||||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8=
|
github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8=
|
||||||
@@ -42,11 +40,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
|
||||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
|
||||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -65,15 +59,11 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
|||||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
|
||||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
|
||||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||||
@@ -83,8 +73,6 @@ github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16p
|
|||||||
github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g=
|
github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g=
|
||||||
github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0=
|
github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
|
||||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
@@ -115,19 +103,11 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
|||||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
|
||||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
|
||||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||||
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
|
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
|
||||||
@@ -164,8 +144,6 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk
|
|||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -183,16 +161,12 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
|
||||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
||||||
@@ -242,16 +216,13 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
|||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run starts the actuator loop. Blocks until ctx is cancelled.
|
// Run starts the actuator loop. Blocks until ctx is cancelled.
|
||||||
@@ -403,7 +402,7 @@ func ProvisionVM(ctx context.Context, pool *db.Pool, entityID uuid.UUID, attrs m
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sshExecSimple runs a command over SSH with a simple client setup.
|
// sshExecSimple runs a command over SSH using the shared dial/run primitives.
|
||||||
// Uses the default SSH key from SSH_KEY_PATH or ~/.ssh/id_rsa.
|
// Uses the default SSH key from SSH_KEY_PATH or ~/.ssh/id_rsa.
|
||||||
func sshExecSimple(ctx context.Context, host, user, command string) (string, error) {
|
func sshExecSimple(ctx context.Context, host, user, command string) (string, error) {
|
||||||
keyPath := os.Getenv("SSH_KEY_PATH")
|
keyPath := os.Getenv("SSH_KEY_PATH")
|
||||||
@@ -411,55 +410,19 @@ func sshExecSimple(ctx context.Context, host, user, command string) (string, err
|
|||||||
keyPath = os.Getenv("HOME") + "/.ssh/id_rsa"
|
keyPath = os.Getenv("HOME") + "/.ssh/id_rsa"
|
||||||
}
|
}
|
||||||
|
|
||||||
keyBytes, err := os.ReadFile(keyPath)
|
signer, err := LoadSigner(keyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read ssh key: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
signer, err := ssh.ParsePrivateKey(keyBytes)
|
client, err := Dial(ctx, DialOptions{Host: host, User: user, Signer: signer})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse ssh key: %w", err)
|
return "", err
|
||||||
}
|
|
||||||
|
|
||||||
clientCfg := &ssh.ClientConfig{
|
|
||||||
User: user,
|
|
||||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := ssh.Dial("tcp", host+":22", clientCfg)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("ssh dial %s: %w", host, err)
|
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
session, err := client.NewSession()
|
out, err := RunCombinedOutput(ctx, client, command)
|
||||||
if err != nil {
|
return string(out), err
|
||||||
return "", fmt.Errorf("create session: %w", err)
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
|
|
||||||
type result struct {
|
|
||||||
output string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
ch := make(chan result, 1)
|
|
||||||
go func() {
|
|
||||||
out, e := session.CombinedOutput(command)
|
|
||||||
ch <- result{output: string(out), err: e}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
session.Close()
|
|
||||||
return "", ctx.Err()
|
|
||||||
case res := <-ch:
|
|
||||||
if res.err != nil {
|
|
||||||
return res.output, res.err
|
|
||||||
}
|
|
||||||
return res.output, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveHost resolves a host entity slug to (address, user) for SSH.
|
// resolveHost resolves a host entity slug to (address, user) for SSH.
|
||||||
|
|||||||
141
internal/actuator/client.go
Normal file
141
internal/actuator/client.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package actuator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultDialTimeout bounds an SSH dial when the caller leaves Timeout unset.
|
||||||
|
// 10s matches the previous hardcoded value at every dial site.
|
||||||
|
const defaultDialTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// LoadSigner reads and parses the private key at keyPath.
|
||||||
|
func LoadSigner(keyPath string) (ssh.Signer, error) {
|
||||||
|
key, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ssh key: %w", err)
|
||||||
|
}
|
||||||
|
return LoadSignerFromBytes(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSignerFromBytes parses an in-memory private key into an ssh.Signer.
|
||||||
|
func LoadSignerFromBytes(key []byte) (ssh.Signer, error) {
|
||||||
|
signer, err := ssh.ParsePrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ssh key: %w", err)
|
||||||
|
}
|
||||||
|
return signer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialOptions configures an SSH dial.
|
||||||
|
type DialOptions struct {
|
||||||
|
Host string
|
||||||
|
Port int // 0 means 22
|
||||||
|
User string
|
||||||
|
Signer ssh.Signer
|
||||||
|
Timeout time.Duration // dial timeout; <=0 means defaultDialTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial opens a crypto/ssh connection through the centralized HostKeyCallback.
|
||||||
|
// The connection itself is bounded by Timeout; ctx is respected by callers
|
||||||
|
// via RunCombinedOutput once the session is running.
|
||||||
|
func Dial(ctx context.Context, opts DialOptions) (*ssh.Client, error) {
|
||||||
|
port := opts.Port
|
||||||
|
if port <= 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
timeout := opts.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultDialTimeout
|
||||||
|
}
|
||||||
|
cfg := &ssh.ClientConfig{
|
||||||
|
User: opts.User,
|
||||||
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(opts.Signer)},
|
||||||
|
HostKeyCallback: HostKeyCallback(),
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(opts.Host, fmt.Sprintf("%d", port))
|
||||||
|
client, err := ssh.Dial("tcp", addr, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ssh dial %s:%d: %w", opts.Host, port, err)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCombinedOutput runs cmd on an established client and returns its combined
|
||||||
|
// stdout/stderr. Context cancellation closes the session to abort the remote
|
||||||
|
// command instead of blocking until it finishes — the same goroutine+select
|
||||||
|
// pattern the actuator, mcp, and scheduler each reimplemented before.
|
||||||
|
func RunCombinedOutput(ctx context.Context, client *ssh.Client, cmd string) ([]byte, error) {
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
out []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
out, err := session.CombinedOutput(cmd)
|
||||||
|
ch <- result{out: out, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
session.Close()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case res := <-ch:
|
||||||
|
if res.err != nil {
|
||||||
|
return res.out, fmt.Errorf("command: %w", res.err)
|
||||||
|
}
|
||||||
|
return res.out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunOutput runs cmd on an established client and returns stdout only.
|
||||||
|
// Stderr is folded into the returned error so callers that parse stdout
|
||||||
|
// as JSON (e.g. the scheduler's check scripts) don't get interleaved
|
||||||
|
// stderr in the output stream.
|
||||||
|
func RunOutput(ctx context.Context, client *ssh.Client, cmd string) ([]byte, error) {
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
var outBuf, errBuf bytes.Buffer
|
||||||
|
session.Stdout = &outBuf
|
||||||
|
session.Stderr = &errBuf
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
runErr error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
ch <- result{runErr: session.Run(cmd)}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
session.Close()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case res := <-ch:
|
||||||
|
if res.runErr != nil {
|
||||||
|
if errBuf.Len() > 0 {
|
||||||
|
return outBuf.Bytes(), fmt.Errorf("command: %w\nstderr: %s", res.runErr, strings.TrimSpace(errBuf.String()))
|
||||||
|
}
|
||||||
|
return outBuf.Bytes(), fmt.Errorf("command: %w", res.runErr)
|
||||||
|
}
|
||||||
|
return outBuf.Bytes(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
88
internal/actuator/client_test.go
Normal file
88
internal/actuator/client_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package actuator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/pem"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadSignerRejectsBadInput(t *testing.T) {
|
||||||
|
if _, err := LoadSignerFromBytes([]byte("not a private key")); err == nil {
|
||||||
|
t.Error("LoadSignerFromBytes should reject a non-key input")
|
||||||
|
}
|
||||||
|
if _, err := LoadSigner("/nonexistent/key"); err == nil {
|
||||||
|
t.Error("LoadSigner should fail on a missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSignerRoundTrip(t *testing.T) {
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
block, err := ssh.MarshalPrivateKey(priv, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal private key: %v", err)
|
||||||
|
}
|
||||||
|
pemBytes := pem.EncodeToMemory(block)
|
||||||
|
|
||||||
|
signer, err := LoadSignerFromBytes(pemBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSignerFromBytes on a valid key: %v", err)
|
||||||
|
}
|
||||||
|
if signer == nil {
|
||||||
|
t.Fatal("signer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "id_ed25519")
|
||||||
|
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
|
||||||
|
t.Fatalf("write key file: %v", err)
|
||||||
|
}
|
||||||
|
fromFile, err := LoadSigner(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSigner(%s): %v", path, err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(fromFile.PublicKey().Marshal(), signer.PublicKey().Marshal()) {
|
||||||
|
t.Error("file and in-memory signers resolved to different public keys")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial needs a real SSH server to run a command, but its option normalization
|
||||||
|
// is verifiable without one: a zero Port must default to 22 (so the dial error
|
||||||
|
// references host:22, not host:0), and a closed port yields a dial error rather
|
||||||
|
// than panicking.
|
||||||
|
func TestDialDefaultsPort(t *testing.T) {
|
||||||
|
_, err := Dial(context.Background(), DialOptions{Host: "127.0.0.1", Signer: mustSigner(t)})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Dial to a closed port should fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "127.0.0.1:22") {
|
||||||
|
t.Errorf("Dial error = %q, want it to reference 127.0.0.1:22", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustSigner(t *testing.T) ssh.Signer {
|
||||||
|
t.Helper()
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
block, err := ssh.MarshalPrivateKey(priv, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal key: %v", err)
|
||||||
|
}
|
||||||
|
s, err := LoadSignerFromBytes(pem.EncodeToMemory(block))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse key: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
129
internal/actuator/hostkeys.go
Normal file
129
internal/actuator/hostkeys.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package actuator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
hostKeyMu sync.RWMutex
|
||||||
|
hostKeyCache map[string]ssh.PublicKey
|
||||||
|
hostKeyOnce sync.Once
|
||||||
|
hostKeySrc HostKeySource
|
||||||
|
)
|
||||||
|
|
||||||
|
// HostKeySource provides storage for SSH host public keys.
|
||||||
|
type HostKeySource interface {
|
||||||
|
GetHostKey(ctx context.Context, hostname string) (string, error)
|
||||||
|
SetHostKey(ctx context.Context, hostname string, key string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHostKeySource sets the host key source. Must be called before
|
||||||
|
// any SSH connections. A nil source enables TOFU-only mode (keys
|
||||||
|
// accepted in memory but not persisted).
|
||||||
|
func SetHostKeySource(src HostKeySource) {
|
||||||
|
hostKeyMu.Lock()
|
||||||
|
defer hostKeyMu.Unlock()
|
||||||
|
hostKeySrc = src
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostKeyCallback returns an ssh.HostKeyCallback that verifies host keys.
|
||||||
|
// Known keys are verified (MITM detection). Unknown keys are accepted
|
||||||
|
// via TOFU and optionally persisted to the source.
|
||||||
|
func HostKeyCallback() ssh.HostKeyCallback {
|
||||||
|
return hostKeyVerify
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostKeyVerify(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
|
hostKeyOnce.Do(func() {
|
||||||
|
hostKeyCache = make(map[string]ssh.PublicKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
normalized := hostWithoutPort(hostname)
|
||||||
|
|
||||||
|
hostKeyMu.RLock()
|
||||||
|
known, exists := hostKeyCache[normalized]
|
||||||
|
hostKeyMu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
if bytes.Equal(key.Marshal(), known.Marshal()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("SSH HOST KEY CHANGED for %s (possible MITM)", normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostKeyMu.Lock()
|
||||||
|
hostKeyCache[normalized] = key
|
||||||
|
hostKeyMu.Unlock()
|
||||||
|
|
||||||
|
slog.Info("ssh: accepting new host key (TOFU)", "host", normalized)
|
||||||
|
|
||||||
|
if hostKeySrc != nil {
|
||||||
|
go persistHostKey(normalized, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistHostKey(hostname string, key ssh.PublicKey) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
keyBase64 := key.Type() + " " + string(key.Marshal())
|
||||||
|
if err := hostKeySrc.SetHostKey(ctx, "ssh/host-keys/"+hostname, keyBase64); err != nil {
|
||||||
|
slog.Warn("ssh: failed to persist host key", "host", hostname, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadHostKeys pre-loads known host keys from the source into the
|
||||||
|
// in-memory cache. Call at startup to avoid TOFU on first connection.
|
||||||
|
// The source should return key lines in the format "key-type base64-data".
|
||||||
|
func LoadHostKeys(ctx context.Context, hostnames []string, src HostKeySource) {
|
||||||
|
if src == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SetHostKeySource(src)
|
||||||
|
|
||||||
|
hostKeyMu.Lock()
|
||||||
|
defer hostKeyMu.Unlock()
|
||||||
|
|
||||||
|
if hostKeyCache == nil {
|
||||||
|
hostKeyCache = make(map[string]ssh.PublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded := 0
|
||||||
|
for _, hostname := range hostnames {
|
||||||
|
keyData, err := src.GetHostKey(ctx, "ssh/host-keys/"+hostname)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("ssh: no stored key for host", "host", hostname, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyData))
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("ssh: invalid stored key for host", "host", hostname, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hostKeyCache[hostname] = pubKey
|
||||||
|
loaded++
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded > 0 {
|
||||||
|
slog.Info("ssh: loaded host keys from Infisical", "count", loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostWithoutPort(hostname string) string {
|
||||||
|
for i := len(hostname) - 1; i >= 0; i-- {
|
||||||
|
if hostname[i] == ':' {
|
||||||
|
return hostname[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
56
internal/actuator/hostkeys_infisical.go
Normal file
56
internal/actuator/hostkeys_infisical.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package actuator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
|
"github.com/dtoro/oikos/internal/secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InfisicalHostKeySource implements HostKeySource backed by Infisical.
|
||||||
|
type InfisicalHostKeySource struct {
|
||||||
|
sec secrets.Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInfisicalHostKeySource creates a HostKeySource that reads/writes
|
||||||
|
// SSH host public keys from Infisical under the `ssh/host-keys/` prefix.
|
||||||
|
func NewInfisicalHostKeySource(sec secrets.Backend) *InfisicalHostKeySource {
|
||||||
|
return &InfisicalHostKeySource{sec: sec}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InfisicalHostKeySource) GetHostKey(ctx context.Context, path string) (string, error) {
|
||||||
|
val, err := s.sec.Get(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InfisicalHostKeySource) SetHostKey(ctx context.Context, path string, key string) error {
|
||||||
|
return s.sec.Set(ctx, path, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSSHHosts queries the DB for active proxmox-host and standalone-server
|
||||||
|
// entities, returning their slugs as SSH host identifiers.
|
||||||
|
func ResolveSSHHosts(ctx context.Context, pool *db.Pool) []string {
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT slug FROM entities
|
||||||
|
WHERE type IN ('proxmox-host', 'standalone-server')
|
||||||
|
AND state = 'active'
|
||||||
|
ORDER BY slug`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("ssh: failed to list hosts", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var hosts []string
|
||||||
|
for rows.Next() {
|
||||||
|
var slug string
|
||||||
|
if rows.Scan(&slug) == nil {
|
||||||
|
hosts = append(hosts, slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
122
internal/actuator/pool.go
Normal file
122
internal/actuator/pool.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package actuator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type poolEntry struct {
|
||||||
|
client *ssh.Client
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type DialPool struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries map[string]*poolEntry
|
||||||
|
ttl time.Duration
|
||||||
|
done chan struct{}
|
||||||
|
stopped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDialPool(ttl time.Duration) *DialPool {
|
||||||
|
p := &DialPool{
|
||||||
|
entries: make(map[string]*poolEntry),
|
||||||
|
ttl: ttl,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
if ttl > 0 {
|
||||||
|
go p.evictLoop()
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DialPool) key(opts DialOptions) string {
|
||||||
|
port := opts.Port
|
||||||
|
if port <= 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%d", opts.Host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DialPool) Get(ctx context.Context, opts DialOptions) (*ssh.Client, error) {
|
||||||
|
k := p.key(opts)
|
||||||
|
p.mu.RLock()
|
||||||
|
entry, ok := p.entries[k]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
// Quick health check: a session can be created without running a
|
||||||
|
// command — if it fails, the connection is dead and we evict it.
|
||||||
|
testSession, err := entry.client.NewSession()
|
||||||
|
if err == nil {
|
||||||
|
testSession.Close()
|
||||||
|
return entry.client, nil
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.entries[k] == entry {
|
||||||
|
entry.client.Close()
|
||||||
|
delete(p.entries, k)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
// Fall through to dial below
|
||||||
|
}
|
||||||
|
client, err := Dial(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.stopped {
|
||||||
|
p.mu.Unlock()
|
||||||
|
client.Close()
|
||||||
|
return nil, fmt.Errorf("ssh dial pool: closed")
|
||||||
|
}
|
||||||
|
if existing, ok2 := p.entries[k]; ok2 {
|
||||||
|
p.mu.Unlock()
|
||||||
|
client.Close()
|
||||||
|
return existing.client, nil
|
||||||
|
}
|
||||||
|
p.entries[k] = &poolEntry{client: client, createdAt: time.Now()}
|
||||||
|
p.mu.Unlock()
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DialPool) Close() {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.stopped = true
|
||||||
|
for k, entry := range p.entries {
|
||||||
|
entry.client.Close()
|
||||||
|
delete(p.entries, k)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
if p.ttl > 0 {
|
||||||
|
close(p.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DialPool) evictLoop() {
|
||||||
|
ticker := time.NewTicker(p.ttl / 2)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
p.evict()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DialPool) evict() {
|
||||||
|
deadline := time.Now().Add(-p.ttl)
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for k, entry := range p.entries {
|
||||||
|
if entry.createdAt.Before(deadline) {
|
||||||
|
entry.client.Close()
|
||||||
|
delete(p.entries, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -131,37 +130,19 @@ func ExecuteProcedure(
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// Parse the SSH key
|
// Parse the SSH key
|
||||||
key, err := os.ReadFile(cfg.KeyPath)
|
signer, err := LoadSigner(cfg.KeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SSHResult{
|
return SSHResult{
|
||||||
Err: fmt.Errorf("read ssh key: %w", err),
|
Err: err,
|
||||||
Duration: time.Since(start),
|
Duration: time.Since(start),
|
||||||
Verified: false,
|
Verified: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signer, err := ssh.ParsePrivateKey(key)
|
client, err := Dial(ctx, DialOptions{
|
||||||
if err != nil {
|
Host: cfg.Host, Port: cfg.Port, User: cfg.User,
|
||||||
return SSHResult{
|
Signer: signer, Timeout: cfg.Timeout,
|
||||||
Err: fmt.Errorf("parse ssh key: %w", err),
|
})
|
||||||
Duration: time.Since(start),
|
|
||||||
Verified: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
|
||||||
if cfg.Port == 0 {
|
|
||||||
addr = net.JoinHostPort(cfg.Host, "22")
|
|
||||||
}
|
|
||||||
|
|
||||||
clientCfg := &ssh.ClientConfig{
|
|
||||||
User: cfg.User,
|
|
||||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // restricted key; host trust via inventory
|
|
||||||
Timeout: cfg.Timeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := ssh.Dial("tcp", addr, clientCfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
class := classifySSHError(err)
|
class := classifySSHError(err)
|
||||||
return SSHResult{
|
return SSHResult{
|
||||||
@@ -229,38 +210,11 @@ func ExecuteProcedure(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSSHCommand executes a single command over an established SSH session.
|
// runSSHCommand executes a single command over an established SSH session via
|
||||||
// Uses context-aware goroutines: ctx.Done() closes the session.
|
// the shared RunCombinedOutput primitive (context-aware abort + combined output).
|
||||||
func runSSHCommand(ctx context.Context, client *ssh.Client, command string) (string, error) {
|
func runSSHCommand(ctx context.Context, client *ssh.Client, command string) (string, error) {
|
||||||
session, err := client.NewSession()
|
out, err := RunCombinedOutput(ctx, client, command)
|
||||||
if err != nil {
|
return string(out), err
|
||||||
return "", fmt.Errorf("create session: %w", err)
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
|
|
||||||
// Wrap in goroutine so we can abort on ctx.Done()
|
|
||||||
type result struct {
|
|
||||||
output string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
ch := make(chan result, 1)
|
|
||||||
go func() {
|
|
||||||
out, err := session.CombinedOutput(command)
|
|
||||||
ch <- result{output: string(out), err: err}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Close the session to abort the SSH command
|
|
||||||
session.Close()
|
|
||||||
return "", ctx.Err()
|
|
||||||
case res := <-ch:
|
|
||||||
if res.err != nil {
|
|
||||||
return res.output, fmt.Errorf("command: %w", res.err)
|
|
||||||
}
|
|
||||||
return res.output, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Procedure parsing ────────────────────────────────────────────────────
|
// ─── Procedure parsing ────────────────────────────────────────────────────
|
||||||
|
|||||||
86
internal/actuator/stream.go
Normal file
86
internal/actuator/stream.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package actuator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunStreaming runs a command on an established SSH client and forwards output
|
||||||
|
// chunks to sink as they arrive. A nil sink collects output silently. Returns
|
||||||
|
// the full combined output and any command error.
|
||||||
|
func RunStreaming(ctx context.Context, client *ssh.Client, command string, sink func(stream string, chunk []byte), timeout time.Duration) (string, error) {
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
outPipe, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
errPipe, err := session.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stderr pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamResult struct {
|
||||||
|
out string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
resultCh := make(chan streamResult, 1)
|
||||||
|
go func() {
|
||||||
|
var combined []byte
|
||||||
|
done := make(chan struct{}, 2)
|
||||||
|
|
||||||
|
readStream := func(stream string, r io.Reader) {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Bytes()
|
||||||
|
chunk := make([]byte, len(line))
|
||||||
|
copy(chunk, line)
|
||||||
|
if sink != nil {
|
||||||
|
sink(stream, chunk)
|
||||||
|
}
|
||||||
|
if stream == "stdout" || stream == "" {
|
||||||
|
if len(combined) > 0 {
|
||||||
|
combined = append(combined, '\n')
|
||||||
|
}
|
||||||
|
combined = append(combined, chunk...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
go readStream("stdout", outPipe)
|
||||||
|
go readStream("stderr", errPipe)
|
||||||
|
|
||||||
|
runErr := session.Run(command)
|
||||||
|
<-done
|
||||||
|
<-done
|
||||||
|
resultCh <- streamResult{out: string(combined), err: runErr}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
session.Close()
|
||||||
|
return "", ctx.Err()
|
||||||
|
case res := <-resultCh:
|
||||||
|
if res.err != nil {
|
||||||
|
return res.out, fmt.Errorf("command: %w", res.err)
|
||||||
|
}
|
||||||
|
return res.out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
5
internal/adapters/doc.go
Normal file
5
internal/adapters/doc.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Package adapters hosts the ports' implementations (postgres, ssh, probes,
|
||||||
|
// remote, secrets, events) and the driving adapters (httpapi, mcpserver,
|
||||||
|
// scheduler, execworker, cli). Packages move here phase by phase per
|
||||||
|
// plans/2026-08-15-hexagonal-architecture.md.
|
||||||
|
package adapters
|
||||||
34
internal/adapters/postgres/checks.go
Normal file
34
internal/adapters/postgres/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
|
||||||
|
}
|
||||||
6
internal/adapters/postgres/doc.go
Normal file
6
internal/adapters/postgres/doc.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Package db is the postgres adapter: connection pool, migrations, seed
|
||||||
|
// ingest, and sqlc-generated queries. It moved from internal/db in Phase 2
|
||||||
|
// of the hexagonal refactor (ADR 0016); the package identifier stays `db`
|
||||||
|
// until the repository split (Phase 3) renames it alongside the first
|
||||||
|
// ports implementations landing here.
|
||||||
|
package db
|
||||||
68
internal/adapters/postgres/entity_cache.go
Normal file
68
internal/adapters/postgres/entity_cache.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type entityCacheEntry struct {
|
||||||
|
slug string
|
||||||
|
id string
|
||||||
|
attrs string
|
||||||
|
exp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntityCache is a TTL cache mapping entity IDs to slugs and back,
|
||||||
|
// keyed for the hot resolution paths.
|
||||||
|
type EntityCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
m map[string]entityCacheEntry
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEntityCache builds a cache with the given TTL.
|
||||||
|
func NewEntityCache(ttl time.Duration) *EntityCache {
|
||||||
|
return &EntityCache{
|
||||||
|
m: make(map[string]entityCacheEntry),
|
||||||
|
ttl: ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSlug resolves an entity ID to its slug.
|
||||||
|
func (c *EntityCache) GetSlug(id string) (string, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
e, ok := c.m[id]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if !ok || time.Now().After(e.exp) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return e.slug, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID resolves a slug to its entity ID.
|
||||||
|
func (c *EntityCache) GetID(slug string) (string, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
e, ok := c.m[slug]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if !ok || time.Now().After(e.exp) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return e.id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set records the slug/id pair and serialized attributes.
|
||||||
|
func (c *EntityCache) Set(slug, id, attrs string) {
|
||||||
|
exp := time.Now().Add(c.ttl)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.m[slug] = entityCacheEntry{slug: slug, id: id, attrs: attrs, exp: exp}
|
||||||
|
c.m[id] = entityCacheEntry{slug: slug, id: id, attrs: attrs, exp: exp}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate drops the cached entries for one slug/id pair.
|
||||||
|
func (c *EntityCache) Invalidate(slug, id string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.m, slug)
|
||||||
|
delete(c.m, id)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/domain"
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
182
internal/adapters/postgres/lifecycle.go
Normal file
182
internal/adapters/postgres/lifecycle.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/adapters/postgres/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":
|
||||||
|
attrs, err := fetchAttrs(ctx, tx, entityID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"backups-verified": "backups_verified",
|
||||||
|
"secrets-revoked": "secrets_revoked",
|
||||||
|
"ingress-dns-removed": "ingress_dns_removed",
|
||||||
|
}[check]
|
||||||
|
if !attrTruthy(attrs, want) {
|
||||||
|
return fmt.Errorf("%s not recorded in entity attributes", want)
|
||||||
|
}
|
||||||
|
case "age-key-enrolled-if-needed":
|
||||||
|
if entityType == "workstation" {
|
||||||
|
attrs, err := fetchAttrs(ctx, tx, entityID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !attrTruthy(attrs, "age_pubkey") {
|
||||||
|
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "mesh-joined-if-needed":
|
||||||
|
if entityType == "workstation" {
|
||||||
|
attrs, err := fetchAttrs(ctx, tx, entityID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !attrTruthy(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAttrs loads an entity's JSONB attributes column as a decoded map.
|
||||||
|
// Missing attributes decode to an empty map (every key absent).
|
||||||
|
func fetchAttrs(ctx context.Context, tx pgx.Tx, entityID uuid.UUID) (map[string]any, error) {
|
||||||
|
var raw string
|
||||||
|
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var attrs map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &attrs); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode entity attributes: %w", err)
|
||||||
|
}
|
||||||
|
if attrs == nil {
|
||||||
|
attrs = map[string]any{}
|
||||||
|
}
|
||||||
|
return attrs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// attrTruthy reports whether key is present in attrs with a meaningful value.
|
||||||
|
// It replaces substring matching on raw JSONB text: a previous strings.Contains
|
||||||
|
// check treated {"backups_verified": false} as satisfied (the key text was
|
||||||
|
// present) and bypassed the attributes GIN index. Booleans must be true;
|
||||||
|
// strings must be non-empty; nil/absent fail.
|
||||||
|
func attrTruthy(attrs map[string]any, key string) bool {
|
||||||
|
v, ok := attrs[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return t
|
||||||
|
case string:
|
||||||
|
return t != ""
|
||||||
|
default:
|
||||||
|
return true // numbers, objects, arrays count as present
|
||||||
|
}
|
||||||
|
}
|
||||||
55
internal/adapters/postgres/lifecycle_test.go
Normal file
55
internal/adapters/postgres/lifecycle_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// attrTruthy replaces a previous strings.Contains check over raw JSONB text.
|
||||||
|
// The key regression it guards: a literal attribute like
|
||||||
|
// {"backups_verified": false} must NOT satisfy the "backups-verified"
|
||||||
|
// precondition, even though the key text is present in the column.
|
||||||
|
func TestAttrTruthy(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
attrs map[string]any
|
||||||
|
key string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"absent", map[string]any{}, "backups_verified", false},
|
||||||
|
{"nil map", nil, "backups_verified", false},
|
||||||
|
{"explicit nil value", map[string]any{"backups_verified": nil}, "backups_verified", false},
|
||||||
|
{"bool true", map[string]any{"backups_verified": true}, "backups_verified", true},
|
||||||
|
{"bool false is the regression case", map[string]any{"backups_verified": false}, "backups_verified", false},
|
||||||
|
{"nonempty string age pubkey", map[string]any{"age_pubkey": "age1abc"}, "age_pubkey", true},
|
||||||
|
{"empty string is falsy", map[string]any{"mesh_ip": ""}, "mesh_ip", false},
|
||||||
|
{"number counts as present", map[string]any{"port": float64(22)}, "port", true},
|
||||||
|
{"other keys present", map[string]any{"backups_verified": true, "unrelated": "x"}, "backups_verified", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := attrTruthy(tc.attrs, tc.key); got != tc.want {
|
||||||
|
t.Fatalf("attrTruthy(%v, %q) = %v, want %v", tc.attrs, tc.key, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAttrs decodes the JSONB column text; verify the decode shape that
|
||||||
|
// attrTruthy then evaluates (the DB round-trip itself is covered by make test-db).
|
||||||
|
func TestAttrTruthyAfterDecode(t *testing.T) {
|
||||||
|
raw := `{"backups_verified": true, "mesh_ip": "10.0.0.5", "secrets_revoked": false}`
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &got); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if !attrTruthy(got, "backups_verified") {
|
||||||
|
t.Error("backups_verified should be truthy after decode")
|
||||||
|
}
|
||||||
|
if !attrTruthy(got, "mesh_ip") {
|
||||||
|
t.Error("mesh_ip should be truthy after decode")
|
||||||
|
}
|
||||||
|
if attrTruthy(got, "secrets_revoked") {
|
||||||
|
t.Error("secrets_revoked:false is the regression — must be falsy")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,11 @@ func (p *Pool) Migrate(ctx context.Context) error {
|
|||||||
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
||||||
return fmt.Errorf("acquire migration lock: %w", err)
|
return fmt.Errorf("acquire migration lock: %w", err)
|
||||||
}
|
}
|
||||||
defer conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey)
|
defer func() {
|
||||||
|
if _, err := conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil {
|
||||||
|
slog.Warn("postgres: release migration lock failed", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Create tracking table if not exists
|
// Create tracking table if not exists
|
||||||
_, err = conn.Exec(ctx, `
|
_, err = conn.Exec(ctx, `
|
||||||
@@ -156,7 +160,11 @@ func (p *Pool) SeedIngest(ctx context.Context, filename string, content []byte,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin tx: %w", err)
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer func() {
|
||||||
|
if err := tx.Rollback(ctx); err != nil {
|
||||||
|
slog.Debug("postgres: rollback after failed ingest", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if err := ingestFn(ctx, tx, data); err != nil {
|
if err := ingestFn(ctx, tx, data); err != nil {
|
||||||
return fmt.Errorf("ingest %s: %w", filename, err)
|
return fmt.Errorf("ingest %s: %w", filename, err)
|
||||||
@@ -190,7 +198,10 @@ func hasSuffix(s, suffix string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// splitSQL splits a SQL string into individual statements.
|
// splitSQL splits a SQL string into individual statements.
|
||||||
// Handles $$ ... $$ dollar-quoted blocks and -- line comments.
|
// Handles $$ ... $$ dollar-quoted blocks, $tag$ ... $tag$ tagged quotes,
|
||||||
|
// -- line comments, /* ... */ block comments, and '...' string literals
|
||||||
|
// so that semicolons inside any of these constructs are not treated as
|
||||||
|
// statement boundaries.
|
||||||
func splitSQL(sql string) []string {
|
func splitSQL(sql string) []string {
|
||||||
var statements []string
|
var statements []string
|
||||||
var current strings.Builder
|
var current strings.Builder
|
||||||
@@ -201,7 +212,6 @@ func splitSQL(sql string) []string {
|
|||||||
for i < len(sql) {
|
for i < len(sql) {
|
||||||
// Handle line comments (-- to end of line)
|
// Handle line comments (-- to end of line)
|
||||||
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
|
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
|
||||||
// Skip to end of line
|
|
||||||
for i < len(sql) && sql[i] != '\n' {
|
for i < len(sql) && sql[i] != '\n' {
|
||||||
current.WriteByte(sql[i])
|
current.WriteByte(sql[i])
|
||||||
i++
|
i++
|
||||||
@@ -209,6 +219,34 @@ func splitSQL(sql string) []string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle block comments (/* ... */)
|
||||||
|
if !inDollarQuote && i+1 < len(sql) && sql[i] == '/' && sql[i+1] == '*' {
|
||||||
|
end := strings.Index(sql[i+2:], "*/")
|
||||||
|
if end >= 0 {
|
||||||
|
current.WriteString(sql[i : i+end+4])
|
||||||
|
i += end + 4
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single-quoted string literals ('...')
|
||||||
|
if !inDollarQuote && sql[i] == '\'' {
|
||||||
|
j := i + 1
|
||||||
|
for j < len(sql) {
|
||||||
|
if sql[j] == '\'' {
|
||||||
|
if j+1 < len(sql) && sql[j+1] == '\'' {
|
||||||
|
j += 2 // skip doubled quote ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
current.WriteString(sql[i : j+1])
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Check for dollar-quote start/end
|
// Check for dollar-quote start/end
|
||||||
if !inDollarQuote && sql[i] == '$' {
|
if !inDollarQuote && sql[i] == '$' {
|
||||||
j := i + 1
|
j := i + 1
|
||||||
@@ -44,6 +44,36 @@ UPDATE entities SET
|
|||||||
WHERE id = sqlc.arg('id') AND version = sqlc.arg('version')
|
WHERE id = sqlc.arg('id') AND version = sqlc.arg('version')
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: MergeEntityAttributes :execrows
|
||||||
|
-- Shallow-merge a JSON patch into an entity's attributes (the
|
||||||
|
-- update_entity_attributes MCP/HTTP surface). Replaces the raw
|
||||||
|
-- `attributes = attributes || $2::jsonb` used in entity_tools.go.
|
||||||
|
UPDATE entities SET
|
||||||
|
attributes = attributes || sqlc.arg('patch')::jsonb,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE slug = sqlc.arg('slug');
|
||||||
|
|
||||||
|
-- name: SetEntityState :execrows
|
||||||
|
-- Set an entity's lifecycle state by id (the set_entity_state surface, run
|
||||||
|
-- after db.ValidateTransition). Replaces the raw
|
||||||
|
-- `UPDATE entities SET state = $2 ... WHERE id = $1`.
|
||||||
|
UPDATE entities SET
|
||||||
|
state = sqlc.arg('state'),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = sqlc.arg('id');
|
||||||
|
|
||||||
-- blast_radius(): the recursive-CTE traversal function's TABLE return type
|
-- blast_radius(): the recursive-CTE traversal function's TABLE return type
|
||||||
-- is opaque to sqlc's analyzer — that one query stays hand-written pgx in
|
-- is opaque to sqlc's analyzer — that one query stays hand-written pgx in
|
||||||
-- internal/httpapi (see impl.go).
|
-- internal/httpapi (see entities.go GetBlastRadius).
|
||||||
|
--
|
||||||
|
-- Deliberate raw-SQL exceptions (plan E2): the httpapi entity *read* handlers
|
||||||
|
-- (ListEntities/GetEntity/GetGraph/queryEntities) project a fixed
|
||||||
|
-- `entityCols` column set (entities.* + a LEFT JOIN to entity_status for
|
||||||
|
-- health/last_check_at) and scan it positionally into the oapi-generated
|
||||||
|
-- gen.Entity shape. sqlc generates its own row struct per query and cannot
|
||||||
|
-- emit gen.Entity, so migrating those reads would add a per-call field-by-
|
||||||
|
-- field mapping with no compile-time gain and real column-order risk. They
|
||||||
|
-- stay hand-written pgx, like blast_radius and the seed/export bulk paths
|
||||||
|
-- noted in sqlc.yaml. The mutation/relationship surface (MergeEntityAttributes,
|
||||||
|
-- SetEntityState, InsertRelationshipIfAbsent, EndCurrentRelationship) IS
|
||||||
|
-- migrated and is what the entity CRUD tools now call.
|
||||||
@@ -25,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)
|
||||||
@@ -55,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;
|
||||||
@@ -25,3 +25,18 @@ ORDER BY r.type, se.slug, te.slug;
|
|||||||
-- 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;
|
||||||
|
|
||||||
|
-- name: InsertRelationshipIfAbsent :execrows
|
||||||
|
-- Idempotent relationship insert (the create_relationship surface): no-op if
|
||||||
|
-- an active edge of the same source/target/type already exists. Replaces the
|
||||||
|
-- raw INSERT...WHERE NOT EXISTS used in entity_tools.go.
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT sqlc.arg('source_id'), sqlc.arg('target_id'), sqlc.arg('type'),
|
||||||
|
sqlc.arg('attributes')::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = sqlc.arg('source_id')
|
||||||
|
AND target_id = sqlc.arg('target_id')
|
||||||
|
AND type = sqlc.arg('type')
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
107
internal/adapters/postgres/splitsql_test.go
Normal file
107
internal/adapters/postgres/splitsql_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func nonEmpty(stmts []string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, s := range stmts {
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLBasic(t *testing.T) {
|
||||||
|
stmts := nonEmpty(splitSQL("CREATE TABLE a (id int); CREATE TABLE b (id int);"))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLDollarQuotedFunction(t *testing.T) {
|
||||||
|
sql := `CREATE FUNCTION f() RETURNS int AS $$
|
||||||
|
SELECT 1; SELECT 2;
|
||||||
|
$$ LANGUAGE sql;
|
||||||
|
CREATE TABLE t (id int);`
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stmts[0], "SELECT 1; SELECT 2;") {
|
||||||
|
t.Errorf("dollar-quoted body was split: %q", stmts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLTaggedDollarQuote(t *testing.T) {
|
||||||
|
sql := `DO $body$ BEGIN PERFORM 1; END $body$;SELECT 1;`
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLSemicolonInComment(t *testing.T) {
|
||||||
|
sql := "-- comment with ; semicolon\nCREATE TABLE t (id int); -- trailing; note\nSELECT 1;"
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLSemicolonInStringLiteral(t *testing.T) {
|
||||||
|
sql := `SELECT 'hello; world'; INSERT INTO t VALUES (1);`
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLDollarSignInStringLiteral(t *testing.T) {
|
||||||
|
sql := `SELECT '$100'; SELECT 2;`
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLBlockComment(t *testing.T) {
|
||||||
|
sql := `SELECT 1; /* block; with; semicolons */ SELECT 2;`
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLBlockCommentWithDollarQuote(t *testing.T) {
|
||||||
|
sql := `/* $$ not a dollar quote */ SELECT 1;`
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 1 {
|
||||||
|
t.Fatalf("got %d statements, want 1: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLDoubledQuoteInString(t *testing.T) {
|
||||||
|
sql := `SELECT 'O''Brien'; SELECT 2;`
|
||||||
|
stmts := nonEmpty(splitSQL(sql))
|
||||||
|
if len(stmts) != 2 {
|
||||||
|
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLEmptyInput(t *testing.T) {
|
||||||
|
stmts := nonEmpty(splitSQL(""))
|
||||||
|
if len(stmts) != 0 {
|
||||||
|
t.Fatalf("got %d statements, want 0", len(stmts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSQLNoSemicolon(t *testing.T) {
|
||||||
|
stmts := nonEmpty(splitSQL("SELECT 1"))
|
||||||
|
if len(stmts) != 1 {
|
||||||
|
t.Fatalf("got %d statements, want 1", len(stmts))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,6 +177,52 @@ func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]E
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mergeEntityAttributes = `-- name: MergeEntityAttributes :execrows
|
||||||
|
UPDATE entities SET
|
||||||
|
attributes = attributes || $1::jsonb,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE slug = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type MergeEntityAttributesParams struct {
|
||||||
|
Patch []byte
|
||||||
|
Slug string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shallow-merge a JSON patch into an entity's attributes (the
|
||||||
|
// update_entity_attributes MCP/HTTP surface). Replaces the raw
|
||||||
|
// `attributes = attributes || $2::jsonb` used in entity_tools.go.
|
||||||
|
func (q *Queries) MergeEntityAttributes(ctx context.Context, arg MergeEntityAttributesParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, mergeEntityAttributes, arg.Patch, arg.Slug)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const setEntityState = `-- name: SetEntityState :execrows
|
||||||
|
UPDATE entities SET
|
||||||
|
state = $1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetEntityStateParams struct {
|
||||||
|
State *string
|
||||||
|
ID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set an entity's lifecycle state by id (the set_entity_state surface, run
|
||||||
|
// after db.ValidateTransition). Replaces the raw
|
||||||
|
// `UPDATE entities SET state = $2 ... WHERE id = $1`.
|
||||||
|
func (q *Queries) SetEntityState(ctx context.Context, arg SetEntityStateParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, setEntityState, arg.State, arg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), 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),
|
||||||
@@ -46,6 +46,8 @@ type AgentSession struct {
|
|||||||
Summary string
|
Summary string
|
||||||
EntityID *uuid.UUID
|
EntityID *uuid.UUID
|
||||||
CompletionNudges int32
|
CompletionNudges int32
|
||||||
|
Blocker string
|
||||||
|
ClosedAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Approval struct {
|
type Approval struct {
|
||||||
@@ -110,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 {
|
||||||
@@ -177,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 {
|
||||||
@@ -211,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
|
||||||
@@ -241,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 {
|
||||||
@@ -350,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 {
|
||||||
@@ -366,18 +398,19 @@ type SeedVersion struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SessionPlanStep struct {
|
type SessionPlanStep struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
SessionID uuid.UUID
|
SessionID uuid.UUID
|
||||||
Seq int32
|
Seq int32
|
||||||
Title string
|
Title string
|
||||||
Detail string
|
Detail string
|
||||||
Status string
|
Status string
|
||||||
ExecutionID *uuid.UUID
|
ExecutionID *uuid.UUID
|
||||||
TargetSlug *string
|
TargetSlug *string
|
||||||
StartedAt *time.Time
|
StartedAt *time.Time
|
||||||
FinishedAt *time.Time
|
FinishedAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Generation int32
|
Generation int32
|
||||||
|
ReplacedReason *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionQuestion struct {
|
type SessionQuestion struct {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -694,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 {
|
||||||
@@ -714,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 {
|
||||||
@@ -1126,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)
|
||||||
@@ -1447,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
|
||||||
|
}
|
||||||
@@ -31,6 +31,42 @@ func (q *Queries) EndCurrentRelationship(ctx context.Context, arg EndCurrentRela
|
|||||||
return result.RowsAffected(), nil
|
return result.RowsAffected(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const insertRelationshipIfAbsent = `-- name: InsertRelationshipIfAbsent :execrows
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, $2, $3,
|
||||||
|
$4::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = $1
|
||||||
|
AND target_id = $2
|
||||||
|
AND type = $3
|
||||||
|
AND valid_to IS NULL
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertRelationshipIfAbsentParams struct {
|
||||||
|
SourceID uuid.UUID
|
||||||
|
TargetID uuid.UUID
|
||||||
|
Type string
|
||||||
|
Attributes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent relationship insert (the create_relationship surface): no-op if
|
||||||
|
// an active edge of the same source/target/type already exists. Replaces the
|
||||||
|
// raw INSERT...WHERE NOT EXISTS used in entity_tools.go.
|
||||||
|
func (q *Queries) InsertRelationshipIfAbsent(ctx context.Context, arg InsertRelationshipIfAbsentParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, insertRelationshipIfAbsent,
|
||||||
|
arg.SourceID,
|
||||||
|
arg.TargetID,
|
||||||
|
arg.Type,
|
||||||
|
arg.Attributes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
const listEntityRelations = `-- name: ListEntityRelations :many
|
const listEntityRelations = `-- name: ListEntityRelations :many
|
||||||
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
|
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
|
||||||
r.valid_from, r.valid_to
|
r.valid_from, r.valid_to
|
||||||
@@ -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()
|
||||||
63
internal/adapters/remote/resolver.go
Normal file
63
internal/adapters/remote/resolver.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Package remote implements ports.TargetResolver over internal/remote.
|
||||||
|
// The resolver logic (address preference, guest wrapping, hosting-compute
|
||||||
|
// walks) is unchanged; this adapter maps its results onto the port types.
|
||||||
|
// When the postgres repositories land (Phase 3+), the underlying functions
|
||||||
|
// move into this package on top of ports.EntityRepository.
|
||||||
|
package remote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
postgres "github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
|
intremote "github.com/dtoro/oikos/internal/remote"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolver resolves execution targets from the entity graph.
|
||||||
|
type Resolver struct {
|
||||||
|
pool *postgres.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ports.TargetResolver = (*Resolver)(nil)
|
||||||
|
|
||||||
|
// NewResolver builds a resolver over the postgres pool.
|
||||||
|
func NewResolver(pool *postgres.Pool) *Resolver { return &Resolver{pool: pool} }
|
||||||
|
|
||||||
|
func toPort(t intremote.ExecTarget) ports.Target {
|
||||||
|
return ports.Target{Host: t.Host, User: t.User, Wrap: t.Wrap}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveExecTarget resolves a slug to its execution endpoint.
|
||||||
|
func (r *Resolver) ResolveExecTarget(ctx context.Context, targetSlug string) (ports.Target, error) {
|
||||||
|
t, err := intremote.ResolveExecTarget(ctx, r.pool, targetSlug, intremote.DefaultUser)
|
||||||
|
if err != nil {
|
||||||
|
return ports.Target{}, err
|
||||||
|
}
|
||||||
|
return toPort(t), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveForCheck resolves a check's entity (by ID and type) to its endpoint.
|
||||||
|
func (r *Resolver) ResolveForCheck(ctx context.Context, targetID domain.UUID, targetType string) (ports.Target, error) {
|
||||||
|
id, err := uuid.Parse(string(targetID))
|
||||||
|
if err != nil {
|
||||||
|
return ports.Target{}, err
|
||||||
|
}
|
||||||
|
t, err := intremote.ResolveExecTargetForCheck(ctx, r.pool, id, targetType, intremote.DefaultUser)
|
||||||
|
if err != nil {
|
||||||
|
return ports.Target{}, err
|
||||||
|
}
|
||||||
|
return toPort(t), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveHost resolves a host slug to address and SSH user.
|
||||||
|
func (r *Resolver) ResolveHost(ctx context.Context, hostSlug, fallbackUser string) (string, string, error) {
|
||||||
|
return intremote.ResolveHost(ctx, r.pool, hostSlug, fallbackUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsGuest reports whether an entity type is reached via pct/qm exec.
|
||||||
|
func (r *Resolver) IsGuest(entityType string) bool {
|
||||||
|
return intremote.IsGuest(entityType)
|
||||||
|
}
|
||||||
114
internal/adapters/ssh/executor.go
Normal file
114
internal/adapters/ssh/executor.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// Package ssh implements ports.CommandExecutor over internal/actuator:
|
||||||
|
// the dial pool, host-key handling, and streaming/combined execution.
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cryptossh "golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/actuator"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultExecTimeout = 10 * time.Minute
|
||||||
|
defaultKeyPathEnv = "OIKOS_SSH_KEY_PATH"
|
||||||
|
defaultKeyPath = "/etc/oikos/ssh_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SignerSource supplies the SSH signer used for all dials. The secrets
|
||||||
|
// adapter provides one backed by Infisical/SOPS; tests inject a static one.
|
||||||
|
type SignerSource func(ctx context.Context) (cryptossh.Signer, error)
|
||||||
|
|
||||||
|
// FileSignerSource reads an OpenSSH private key from disk once and parses
|
||||||
|
// it (path from env OIKOS_SSH_KEY_PATH, default /etc/oikos/ssh_key — the
|
||||||
|
// same resolution the httpapi path used before the extraction).
|
||||||
|
func FileSignerSource() SignerSource {
|
||||||
|
var (
|
||||||
|
once sync.Once
|
||||||
|
signer cryptossh.Signer
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
return func(context.Context) (cryptossh.Signer, error) {
|
||||||
|
once.Do(func() {
|
||||||
|
path := os.Getenv(defaultKeyPathEnv)
|
||||||
|
if path == "" {
|
||||||
|
path = defaultKeyPath
|
||||||
|
}
|
||||||
|
key, rerr := os.ReadFile(path)
|
||||||
|
if rerr != nil {
|
||||||
|
err = fmt.Errorf("read ssh key %s: %w", path, rerr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signer, err = actuator.LoadSignerFromBytes(key)
|
||||||
|
})
|
||||||
|
return signer, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executor runs commands over SSH through a dial pool.
|
||||||
|
type Executor struct {
|
||||||
|
signer SignerSource
|
||||||
|
pool *actuator.DialPool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ports.CommandExecutor = (*Executor)(nil)
|
||||||
|
|
||||||
|
// NewExecutor builds an executor. The dial pool reuses connections per
|
||||||
|
// host/user for the given TTL.
|
||||||
|
func NewExecutor(signer SignerSource, poolTTL time.Duration) *Executor {
|
||||||
|
return &Executor{
|
||||||
|
signer: signer,
|
||||||
|
pool: actuator.NewDialPool(poolTTL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases pooled connections.
|
||||||
|
func (e *Executor) Close() { e.pool.Close() }
|
||||||
|
|
||||||
|
// Run dials the target (via the pool), wraps the command for transport when
|
||||||
|
// the target needs it (pct/qm guests), executes with streaming output, and
|
||||||
|
// maps the outcome onto ports.ExecResult.
|
||||||
|
func (e *Executor) Run(ctx context.Context, target ports.Target, command string, opts ports.ExecOpts) ports.ExecResult {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
signer, err := e.signer(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ports.ExecResult{Err: err, Duration: time.Since(start)}
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := e.pool.Get(ctx, actuator.DialOptions{
|
||||||
|
Host: target.Host,
|
||||||
|
User: target.User,
|
||||||
|
Signer: signer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ports.ExecResult{Err: err, Duration: time.Since(start)}
|
||||||
|
}
|
||||||
|
// Pooled client: do not close here; the pool evicts on TTL.
|
||||||
|
|
||||||
|
if target.Wrap != nil {
|
||||||
|
command = target.Wrap(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := opts.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultExecTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
output, runErr := actuator.RunStreaming(ctx, client, command, opts.Sink, timeout)
|
||||||
|
if runErr != nil {
|
||||||
|
slog.Debug("ssh exec: command failed", "host", target.Host, "error", runErr)
|
||||||
|
}
|
||||||
|
return ports.ExecResult{
|
||||||
|
Output: output,
|
||||||
|
Duration: time.Since(start),
|
||||||
|
Err: runErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
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/adapters/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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/adapters/postgres"
|
||||||
|
"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/adapters/postgres"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
202
internal/checkdefaults/build_test.go
Normal file
202
internal/checkdefaults/build_test.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package checkdefaults
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/ontology"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Table-driven coverage of every implemented buildKind branch and the ssh()
|
||||||
|
// helper's user/port/args propagation. The previous tests exercised only
|
||||||
|
// ping/process/http/resource; updates, capacity, backup, cert-expiry,
|
||||||
|
// vm-status and dns were unverified.
|
||||||
|
func TestBuildKindAllImplementedKinds(t *testing.T) {
|
||||||
|
host := "10.0.0.5"
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
kind string
|
||||||
|
target Target
|
||||||
|
attrs map[string]any
|
||||||
|
host string
|
||||||
|
wantSkip bool // true → expect a reason and zero defs
|
||||||
|
wantDefs int
|
||||||
|
wantKind string
|
||||||
|
wantKey string // a config key to assert
|
||||||
|
wantVal any // its expected value
|
||||||
|
wantReason string // substring when skipping
|
||||||
|
wantInterv int32 // expected interval on the (single) produced def
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ping with host", kind: KindPing, host: host,
|
||||||
|
wantDefs: 1, wantKind: "ping", wantKey: "host", wantVal: host, wantInterv: 30,
|
||||||
|
},
|
||||||
|
{name: "ping no host skips", kind: KindPing, wantSkip: true, wantReason: "no address"},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "resource expands to four ssh scripts", kind: KindResource, host: host,
|
||||||
|
wantDefs: 4, wantKind: "ssh-script", wantKey: "host", wantVal: host, wantInterv: 60,
|
||||||
|
},
|
||||||
|
{name: "resource no host skips", kind: KindResource, wantSkip: true, wantReason: "no address"},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "updates is daily", kind: KindUpdates, host: host,
|
||||||
|
wantDefs: 1, wantKind: "ssh-script", wantKey: "script", wantVal: "updates_check.sh", wantInterv: 86400,
|
||||||
|
},
|
||||||
|
{name: "updates no host skips", kind: KindUpdates, wantSkip: true, wantReason: "no address"},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "capacity is one disk script", kind: KindCapacity, host: host,
|
||||||
|
wantDefs: 1, wantKind: "ssh-script", wantKey: "script", wantVal: "disk_usage_check.sh", wantInterv: 60,
|
||||||
|
},
|
||||||
|
{name: "capacity no host skips", kind: KindCapacity, wantSkip: true, wantReason: "no address"},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "backup needs path and host", kind: KindBackup, host: host,
|
||||||
|
attrs: map[string]any{"path": "/backups/db"},
|
||||||
|
wantDefs: 1, wantKind: "backup-freshness", wantKey: "path", wantVal: "/backups/db", wantInterv: 86400,
|
||||||
|
},
|
||||||
|
{name: "backup without path skips", kind: KindBackup, host: host, wantSkip: true, wantReason: "no path"},
|
||||||
|
{name: "backup without host skips", kind: KindBackup, attrs: map[string]any{"path": "/x"}, wantSkip: true, wantReason: "no address"},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "backup honors backup_max_age_s override", kind: KindBackup, host: host,
|
||||||
|
attrs: map[string]any{"path": "/x", "backup_max_age_s": float64(3600)},
|
||||||
|
wantDefs: 1, wantKey: "max_age_s", wantVal: 3600,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "cert-expiry from hostname attr", kind: KindCertExpiry,
|
||||||
|
attrs: map[string]any{"hostname": "media.hubris.network"},
|
||||||
|
wantDefs: 1, wantKind: "cert-expiry", wantKey: "host", wantVal: "media.hubris.network", wantInterv: 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cert-expiry from dotted name", kind: KindCertExpiry, target: Target{Name: "media.hubris.network"},
|
||||||
|
wantDefs: 1, wantKey: "host", wantVal: "media.hubris.network",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cert-expiry propagates dial attr", kind: KindCertExpiry,
|
||||||
|
attrs: map[string]any{"hostname": "media.hubris.network", "dial": "10.0.0.2"},
|
||||||
|
wantDefs: 1, wantKey: "dial", wantVal: "10.0.0.2",
|
||||||
|
},
|
||||||
|
{name: "cert-expiry without a host name skips", kind: KindCertExpiry, target: Target{Name: "jellyfin"}, wantSkip: true, wantReason: "no hostname"},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "vm-status needs pve_id", kind: KindVMStatus, attrs: map[string]any{"pve_id": float64(101)},
|
||||||
|
wantDefs: 1, wantKind: "vm-status", wantInterv: 60,
|
||||||
|
},
|
||||||
|
{name: "vm-status without pve_id skips", kind: KindVMStatus, wantSkip: true, wantReason: "no pve_id"},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "dns resolves entity name", kind: KindDNS, target: Target{Name: "hubris.network"},
|
||||||
|
wantDefs: 1, wantKind: "dns", wantKey: "name", wantVal: "hubris.network", wantInterv: 300,
|
||||||
|
},
|
||||||
|
{name: "dns without a name skips", kind: KindDNS, target: Target{}, wantSkip: true, wantReason: "no name"},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "quorum runs pvecm script via ssh", kind: KindQuorum, host: host,
|
||||||
|
wantDefs: 1, wantKind: "ssh-script", wantKey: "script", wantVal: "pvecm_quorum_check.sh", wantInterv: 60,
|
||||||
|
},
|
||||||
|
{name: "quorum no host skips", kind: KindQuorum, wantSkip: true, wantReason: "no address"},
|
||||||
|
|
||||||
|
{name: "unknown kind skips", kind: "telepathy", host: host, wantSkip: true, wantReason: "no builder"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
defs, reason := buildKind(c.kind, c.target, c.attrs, c.host, "root", 22)
|
||||||
|
if c.wantSkip {
|
||||||
|
if len(defs) != 0 {
|
||||||
|
t.Fatalf("expected zero defs, got %d", len(defs))
|
||||||
|
}
|
||||||
|
if c.wantReason != "" && !strings.Contains(reason, c.wantReason) {
|
||||||
|
t.Errorf("reason = %q, want substring %q", reason, c.wantReason)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(defs) != c.wantDefs {
|
||||||
|
t.Fatalf("got %d defs (%s), want %d", len(defs), reason, c.wantDefs)
|
||||||
|
}
|
||||||
|
if reason != "" {
|
||||||
|
t.Errorf("unexpected skip reason: %q", reason)
|
||||||
|
}
|
||||||
|
if c.wantKind != "" {
|
||||||
|
if got := defs[0].kind; got != c.wantKind {
|
||||||
|
t.Errorf("kind = %q, want %q", got, c.wantKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.wantKey != "" {
|
||||||
|
if got := defs[0].config[c.wantKey]; !reflect.DeepEqual(got, c.wantVal) {
|
||||||
|
t.Errorf("config[%q] = %v (%T), want %v (%T)", c.wantKey, got, got, c.wantVal, c.wantVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.wantInterv != 0 && defs[0].interval != c.wantInterv {
|
||||||
|
t.Errorf("interval = %d, want %d", defs[0].interval, c.wantInterv)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssh() must add user/port/args only when they differ from the root/22/empty
|
||||||
|
// defaults, so generated configs stay minimal and stable across re-seeds.
|
||||||
|
func TestBuildKindSSHOnlyEmitsNonDefaultUserPortArgs(t *testing.T) {
|
||||||
|
t.Run("default root 22 omits user and port", func(t *testing.T) {
|
||||||
|
defs, _ := buildKind(KindResource, Target{}, nil, "10.0.0.1", "root", 22)
|
||||||
|
for _, d := range defs {
|
||||||
|
if _, ok := d.config["user"]; ok {
|
||||||
|
t.Errorf("root should not emit user: %v", d.config)
|
||||||
|
}
|
||||||
|
if _, ok := d.config["port"]; ok {
|
||||||
|
t.Errorf("port 22 should not emit port: %v", d.config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("non-root user and non-22 port are emitted", func(t *testing.T) {
|
||||||
|
defs, _ := buildKind(KindResource, Target{}, nil, "10.0.0.1", "oikos", 2222)
|
||||||
|
if defs[0].config["user"] != "oikos" {
|
||||||
|
t.Errorf("user = %v, want oikos", defs[0].config["user"])
|
||||||
|
}
|
||||||
|
if defs[0].config["port"] != 2222 {
|
||||||
|
t.Errorf("port = %v, want 2222", defs[0].config["port"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("process unit name lands in args", func(t *testing.T) {
|
||||||
|
defs, _ := buildKind(KindProcess, Target{Name: "jellyfin"}, nil, "10.0.0.1", "root", 22)
|
||||||
|
if defs[0].config["args"] != "jellyfin" {
|
||||||
|
t.Errorf("args = %v, want jellyfin", defs[0].config["args"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMonitoringAttr implements the entity-level `monitoring` override
|
||||||
|
// (project decision health_checks.monitoring_override): "none"/"" opts out,
|
||||||
|
// a kind-list replaces the type defaults, anything else falls back.
|
||||||
|
func TestResolveMonitoringAttr(t *testing.T) {
|
||||||
|
fallback := ontology.MonitoringResolution{Declared: true, Kinds: []string{"ping"}, Source: "type"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in any
|
||||||
|
want ontology.MonitoringResolution
|
||||||
|
}{
|
||||||
|
{"none opts out", "none", ontology.MonitoringResolution{Declared: true, Source: "attribute"}},
|
||||||
|
{"empty opts out", "", ontology.MonitoringResolution{Declared: true, Source: "attribute"}},
|
||||||
|
{
|
||||||
|
"kind list overrides",
|
||||||
|
[]any{"http", "process"},
|
||||||
|
ontology.MonitoringResolution{Declared: true, Kinds: []string{"http", "process"}, Source: "attribute"},
|
||||||
|
},
|
||||||
|
{"list drops empty and non-string entries", []any{"http", "", 7, "dns"}, ontology.MonitoringResolution{Declared: true, Kinds: []string{"http", "dns"}, Source: "attribute"}},
|
||||||
|
{"non-string scalar falls back to type default", float64(42), fallback},
|
||||||
|
{"nil falls back", nil, fallback},
|
||||||
|
{"unrecognized string falls back", "weird", fallback},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := resolveMonitoringAttr(c.in, fallback)
|
||||||
|
if !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Errorf("resolveMonitoringAttr(%v) = %+v, want %+v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,550 @@
|
|||||||
|
// 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"
|
||||||
|
KindQuorum = "quorum"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}}, ""
|
||||||
|
|
||||||
|
case KindQuorum:
|
||||||
|
// Proxmox cluster quorum via `pvecm status`. Only meaningful on
|
||||||
|
// proxmox-host entities. Runs every 60s — corosync flaps are
|
||||||
|
// transient and the probe is lightweight (local binary, no network).
|
||||||
|
if host == "" {
|
||||||
|
return nil, "no address on the entity or its host"
|
||||||
|
}
|
||||||
|
return []checkDef{ssh("pvecm_quorum_check.sh")}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
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 +554,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 +576,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,17 @@ type Config struct {
|
|||||||
// port than the API); a no-op when the SPA and API share an origin.
|
// port than the API); a no-op when the SPA and API share an origin.
|
||||||
CORSAllowedOrigin string
|
CORSAllowedOrigin string
|
||||||
|
|
||||||
|
// Rate limiting (plan D3). APIRateLimit is the per-IP requests/sec cap;
|
||||||
|
// APIRateBurst is the token-bucket burst (defaults to 2x the limit when
|
||||||
|
// unset). A limit of 0 disables rate limiting entirely.
|
||||||
|
APIRateLimit int
|
||||||
|
APIRateBurst int
|
||||||
|
|
||||||
|
// Health probe HTTP listener (plan D5). Background-loop roles (scheduler,
|
||||||
|
// execution-worker) expose a staleness-aware /healthz here. Empty disables the
|
||||||
|
// health server (local/non-docker runs).
|
||||||
|
HealthListen string
|
||||||
|
|
||||||
// Observability
|
// Observability
|
||||||
Debug bool // verbose logging, probe payloads, SQL
|
Debug bool // verbose logging, probe payloads, SQL
|
||||||
|
|
||||||
@@ -42,12 +53,6 @@ type Config struct {
|
|||||||
// Scheduler (Phase 3)
|
// Scheduler (Phase 3)
|
||||||
SchedulerInterval time.Duration // check loop interval (default 30s)
|
SchedulerInterval time.Duration // check loop interval (default 30s)
|
||||||
|
|
||||||
// Notifier (Phase 3)
|
|
||||||
MatrixHomeserver string // Matrix server URL
|
|
||||||
MatrixUserID string // bot user ID (e.g. @oikos:matrix.hubris.network)
|
|
||||||
MatrixToken string // Matrix access token
|
|
||||||
MatrixRoomID string // alert room ID
|
|
||||||
|
|
||||||
// Actuator (Phase 3)
|
// Actuator (Phase 3)
|
||||||
SSHKeyPath string // path to the restricted SSH key
|
SSHKeyPath string // path to the restricted SSH key
|
||||||
SSHUser string // SSH user on targets (default "oikos")
|
SSHUser string // SSH user on targets (default "oikos")
|
||||||
@@ -57,9 +62,6 @@ type Config struct {
|
|||||||
// Learning (Phase 3)
|
// Learning (Phase 3)
|
||||||
LearningInterval time.Duration // pattern extraction interval (default 3600s)
|
LearningInterval time.Duration // pattern extraction interval (default 3600s)
|
||||||
|
|
||||||
// Approval HMAC secret (Phase 3)
|
|
||||||
ApprovalHMACSecret string
|
|
||||||
|
|
||||||
// Nomos agent entity ID (Phase 4)
|
// Nomos agent entity ID (Phase 4)
|
||||||
NomosAgentID string
|
NomosAgentID string
|
||||||
NomosAgentSlug string
|
NomosAgentSlug string
|
||||||
@@ -121,6 +123,11 @@ func FromEnv() Config {
|
|||||||
if v := os.Getenv("OIKOS_CORS_ORIGIN"); v != "" {
|
if v := os.Getenv("OIKOS_CORS_ORIGIN"); v != "" {
|
||||||
c.CORSAllowedOrigin = v
|
c.CORSAllowedOrigin = v
|
||||||
}
|
}
|
||||||
|
c.APIRateLimit = parseInt(os.Getenv("OIKOS_API_RATE_LIMIT"))
|
||||||
|
c.APIRateBurst = parseInt(os.Getenv("OIKOS_API_RATE_BURST"))
|
||||||
|
if v := os.Getenv("OIKOS_HEALTH_LISTEN"); v != "" {
|
||||||
|
c.HealthListen = v
|
||||||
|
}
|
||||||
if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" {
|
if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" {
|
||||||
c.SeedsDir = v
|
c.SeedsDir = v
|
||||||
}
|
}
|
||||||
@@ -132,18 +139,6 @@ func FromEnv() Config {
|
|||||||
c.SchedulerInterval = d
|
c.SchedulerInterval = d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v := os.Getenv("OIKOS_MATRIX_HOMESERVER"); v != "" {
|
|
||||||
c.MatrixHomeserver = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("OIKOS_MATRIX_USER"); v != "" {
|
|
||||||
c.MatrixUserID = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("OIKOS_MATRIX_TOKEN"); v != "" {
|
|
||||||
c.MatrixToken = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("OIKOS_MATRIX_ROOM"); v != "" {
|
|
||||||
c.MatrixRoomID = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("OIKOS_SSH_KEY_PATH"); v != "" {
|
if v := os.Getenv("OIKOS_SSH_KEY_PATH"); v != "" {
|
||||||
c.SSHKeyPath = v
|
c.SSHKeyPath = v
|
||||||
}
|
}
|
||||||
@@ -161,9 +156,6 @@ func FromEnv() Config {
|
|||||||
c.LearningInterval = d
|
c.LearningInterval = d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v := os.Getenv("OIKOS_APPROVAL_HMAC_SECRET"); v != "" {
|
|
||||||
c.ApprovalHMACSecret = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("OIKOS_NOMOS_AGENT_ID"); v != "" {
|
if v := os.Getenv("OIKOS_NOMOS_AGENT_ID"); v != "" {
|
||||||
c.NomosAgentID = v
|
c.NomosAgentID = v
|
||||||
}
|
}
|
||||||
|
|||||||
5
internal/core/app/doc.go
Normal file
5
internal/core/app/doc.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Package app hosts the application services (use-cases) of the oikos
|
||||||
|
// core. Services take ports as constructor arguments, return domain types
|
||||||
|
// and sentinel errors, and own the command side of the system. See
|
||||||
|
// docs/adr/0016-hexagonal-ports-adapters.md.
|
||||||
|
package app
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user