Compare commits
25 Commits
0ed171507f
...
desktop-0.
| Author | SHA1 | Date | |
|---|---|---|---|
| a429436903 | |||
| 07d67c8446 | |||
| 8b50753746 | |||
| 680575e2cf | |||
| 6b52c1ae57 | |||
| 5d6d9e9040 | |||
| 04006553a3 | |||
| 7b0a0f01b5 | |||
| f6a699469d | |||
| 4c4afc4783 | |||
| 604b608fa8 | |||
| 62a8ec1d8d | |||
| 335fa67d55 | |||
| d1243aceac | |||
| 61ad785fef | |||
| 35c54ceef5 | |||
| f8e03806aa | |||
| 94c94c0758 | |||
| d80a394b7f | |||
| 0c0f35a3a9 | |||
| 346eb2f144 | |||
| 48827d5bb1 | |||
| 56979ac4bd | |||
| 3157e6102a | |||
| 6807e353e3 |
@@ -1,19 +1,27 @@
|
||||
# NOMOS.md — Agent persona for homelab clients
|
||||
|
||||
This file is the canonical agent persona for **all** AI agents running on
|
||||
machines in the **hubris** homelab. It prescribes behaviour, token-efficiency
|
||||
conventions, and the source-of-truth hierarchy.
|
||||
This file is the canonical agent persona for AI agents running on machines
|
||||
in the **hubris** homelab (Claude Code, Codex, or similar). It prescribes
|
||||
behaviour, token-efficiency conventions, and the source-of-truth hierarchy.
|
||||
|
||||
The *production* Nomos agent (`cmd/nomos`, the containerized MCP client
|
||||
gateway everyone actually talks to) uses a separate, code-adjacent persona —
|
||||
`nomos/SOUL.md`, baked into its Docker image at build time
|
||||
(`compose/nomos/Dockerfile`). This file is unrelated to that one; it's for
|
||||
AI coding agents working *on* a homelab client machine, not the Nomos
|
||||
service itself.
|
||||
|
||||
## Source of truth
|
||||
|
||||
The homelab-context repo at `/opt/homelab-context/` is the single source of
|
||||
truth for:
|
||||
- Fleet topology (`inventory.yaml`, `inventory.yaml`)
|
||||
- Service endpoints and credentials (via `homelab secret`)
|
||||
- Fleet topology (`inventory.yaml`)
|
||||
- Agent behaviour and conventions
|
||||
- Everything in this file
|
||||
|
||||
When in doubt, check `/opt/homelab-context/` first.
|
||||
When in doubt, check `/opt/homelab-context/` first, or query the Oikos API/MCP
|
||||
server directly (see [AGENTS.md](../AGENTS.md) §3-4) — the database is
|
||||
authoritative at runtime.
|
||||
|
||||
## Runbooks — load, don't rediscover
|
||||
|
||||
@@ -28,69 +36,9 @@ wiki when a runbook already encodes it. See [OIKOS.md](OIKOS.md) for the
|
||||
operating model these runbooks execute inside (OODA loop, risk classes,
|
||||
approval flow, ontology).
|
||||
|
||||
## Agent type — how this file gets loaded
|
||||
## Token efficiency
|
||||
|
||||
| Agent | Loading mechanism |
|
||||
|-------|------------------|
|
||||
| **Nomos** | `tools/setup-nomos-soul.sh` (auto-setup) → provisions `~/.nomos/SOUL.md` from this file |
|
||||
| **Goose** | `.goosehints` symlink at `~/.config/goose/.goosehints` → `/opt/homelab-context/NOMOS.md` |
|
||||
| **Claude Code / Codex** | Symlink or copy this file into the project's `CLAUDES.md` / `.claude` instructions |
|
||||
|
||||
**Do not edit SOUL.md or .goosehints directly.** Edit this file in the
|
||||
homelab-context repo instead. Changes propagate to all clients on the next
|
||||
sync (`sudo homelab sync`).
|
||||
|
||||
---
|
||||
|
||||
## Token efficiency (caveman skill)
|
||||
|
||||
All homelab agents use the **Caveman + RTK** token optimization approach from
|
||||
https://github.com/adityahimaone/hermes-agent-rtk-caveman.
|
||||
|
||||
### Before running any CLI command, ask:
|
||||
|
||||
1. **Is there a caveman wrapper equivalent?** Use the wrapper for token-efficient
|
||||
output. Available wrappers (installed at `~/bin/caveman_wrapper.sh`):
|
||||
- `~/bin/caveman_wrapper.sh git-status` — compact git status
|
||||
- `~/bin/caveman_wrapper.sh git-log [n]` — compact git log
|
||||
- `~/bin/caveman_wrapper.sh lint [target]` — compact lint results
|
||||
- `~/bin/caveman_wrapper.sh test-results [cmd]` — compact test results
|
||||
|
||||
2. **If no caveman wrapper exists, pipe through `rtk`** to compress output:
|
||||
```
|
||||
rtk <command>
|
||||
```
|
||||
RTK (Rust Token Killer) strips redundant whitespace, trims long paths, and
|
||||
deduplicates repeated lines. This reduces token usage by 60-90% on CLI
|
||||
operations.
|
||||
|
||||
3. **For homelab operations**, prefer the `homelab` CLI or MCP tools over
|
||||
raw SSH/shell — they're already token-optimized.
|
||||
|
||||
### Templates
|
||||
|
||||
Caveman templates live at `~/templates/`:
|
||||
- `git_status.txt` — compact git status format
|
||||
- `git_log.txt` — compact git log format
|
||||
- `lint_results.txt` — compact ESLint format
|
||||
- `test_results.txt` — compact vitest/jest format
|
||||
|
||||
### When to skip caveman/rtk
|
||||
|
||||
- Interactive commands (editors, prompts) — let human-readable output pass
|
||||
- Commands with no output — skip entirely
|
||||
- When you need the exact raw output for post-processing
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
ls ~/bin/caveman_wrapper.sh && echo "caveman ready"
|
||||
```
|
||||
|
||||
## Important note for Nomos agents
|
||||
|
||||
If you are reading this as a Nomos agent, your SOUL.md was auto-provisioned
|
||||
by `tools/setup-nomos-soul.sh`. This file is the canonical original — you
|
||||
can verify the content matches or re-provision by running:
|
||||
|
||||
bash /opt/homelab-context/tools/setup-nomos-soul.sh
|
||||
Apply [caveman.md](shared/caveman.md) — terse, fragment-heavy chat responses
|
||||
(not committed documentation). There's no separate tool to install for
|
||||
this; it's a response-style convention any agent follows by reading the
|
||||
file.
|
||||
|
||||
@@ -10,6 +10,7 @@ see [CONTRIBUTING.md](../../CONTRIBUTING.md) for a human-friendly version.
|
||||
cmd/oikos/main.go Entry point. Subcommands: api, scheduler, notifier, migrate,
|
||||
seed, export, secret, all
|
||||
cmd/nomos/main.go Nomos MCP client gateway (standalone binary, formerly Hermes)
|
||||
cmd/webhook/main.go Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
|
||||
internal/httpapi/ REST + MCP server. Chi router. OpenAPI-generated types from
|
||||
internal/httpapi/gen/api.gen.go. Strict server in impl.go.
|
||||
internal/mcp/ MCP tool implementations (get_entity, search_knowledge, etc.)
|
||||
@@ -26,14 +27,19 @@ internal/domain/ Core types: entities, approvals, executions, signals
|
||||
internal/ontology/ Type hierarchy validation, relationship checks
|
||||
internal/knowledge/ Knowledge YAML seed ingestion
|
||||
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/codegen.yaml oapi-codegen config → generates internal/httpapi/gen/
|
||||
migrations/ Forward-only SQL. Format: NNN_name.up.sql. No down migrations.
|
||||
seeds/ Bootstrap YAML. ontology.yaml, inventory.yaml, policy.yaml,
|
||||
knowledge.yaml. Regenerated from DB via oikos export.
|
||||
compose/ Dockerfiles. oikos/ (multi-stage), nomos/ (distroless).
|
||||
compose/ Dockerfiles. oikos/ (2-stage, Go only — SPA is built/deployed
|
||||
separately), nomos/ (distroless).
|
||||
Caddy config at compose/caddy/Caddyfile.oikos.
|
||||
scripts/ Deploy, rollback, watchdog, verification, cutover checklist.
|
||||
checks/ Host health-check scripts run over SSH by the scheduler.
|
||||
tools/ Client auto-setup scripts (checks).
|
||||
nomos/ Nomos config.yaml, SOUL.md, skills.
|
||||
.agents/ Agent instruction files, domains, shared conventions, skills.
|
||||
plans/ Design documents. active/ + done/.
|
||||
|
||||
@@ -1,365 +1,120 @@
|
||||
# Agent enrollment — bootstrap a client into the homelab context system
|
||||
# Agent enrollment — operational notes
|
||||
|
||||
This walks through enrolling a new machine (workstation, LXC, or VM) so it
|
||||
joins the cross-client context system: a `/opt/homelab-context/` clone of
|
||||
this repo that auto-syncs every 5 min, a per-client age key for SOPS
|
||||
decryption, the `homelab` CLI, and an MCP endpoint in Claude Code's config.
|
||||
**For the actual enrollment flow, see [CLIENTS.md](../../CLIENTS.md#enrollment)
|
||||
— it's the current, authoritative version.** This page used to duplicate
|
||||
that flow in more detail, describing a `homelab` CLI-based two-step
|
||||
ceremony (`homelab client add` reserves an inventory slot → client
|
||||
bootstraps → operator finalizes the pubkey). That CLI and that flow don't
|
||||
exist anymore — enrollment today is one shot: `bootstrap.sh` calls
|
||||
`POST /api/v1/clients/enroll` directly and gets back an age keypair +
|
||||
Infisical identity in the same response. What's left here is the handful
|
||||
of things that are still true and weren't already covered elsewhere.
|
||||
|
||||
> Onboarding a Nous-Hermes-powered Goose agent on top of standard enrollment?
|
||||
> See [nomos-agent.md](nomos-agent.md). It uses the same `bootstrap.sh`
|
||||
> with an additional `--with-nomos` flag.
|
||||
|
||||
Architecture in [project_homelab_context_plan](https://… memory link); the
|
||||
operational reference is here.
|
||||
|
||||
## Prerequisites the client must satisfy
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Why | How to check |
|
||||
| --- | --- | --- |
|
||||
| Hostname matches an entry in `inventory.yaml` | The bootstrap looks up `hosts/$(hostname).yaml`. | `hostname` (Linux) / `scutil --get LocalHostName` (macOS) |
|
||||
| Hostname matches an entry in `inventory.yaml` | `EnrollClient` looks up the entity by slug derived from hostname; it must exist in `planned`/`provisioning` state. | `hostname` (Linux) / `scutil --get LocalHostName` (macOS) |
|
||||
| OS is Linux or macOS | bootstrap detects via `uname -s` | `uname -s` |
|
||||
| On the mesh (Netbird or Tailscale) **or** on the LAN | issuance is gated to mesh + LAN subnets. **For Netbird: use a setup-key, not interactive auth** — see "Getting onto Netbird" below. | `netbird status` / `tailscale status` |
|
||||
| `git`, `python3`, `python3-yaml`, `age`, `sops` | bootstrap preflight; `homelab` CLI imports yaml | See per-OS commands below |
|
||||
| Can resolve `*.hubris.network` | bootstrap calls `https://secrets.hubris.network/issue` and writes `https://mcp.hubris.network/mcp` | `dig +short mcp.hubris.network` (should return `192.168.8.175`) |
|
||||
| On the mesh (Netbird) **or** on the LAN | enrollment validates mesh IP against expected subnets | `netbird status` |
|
||||
| `curl`, `jq`, `age`, `python3` | bootstrap preflight (`bootstrap.sh:100`) — auto-installed on Fedora/RHEL/Debian/Ubuntu/macOS if missing | `command -v curl jq age python3` |
|
||||
| Can resolve `*.hubris.network` | bootstrap calls the Oikos API and writes `https://mcp.hubris.network/mcp` | `dig +short mcp.hubris.network` |
|
||||
|
||||
### Hostname mismatch is the most common bootstrap failure
|
||||
|
||||
If the bootstrap exits with `no hosts/<name>.yaml in the repo`, the
|
||||
hostname doesn't match any inventory entry. Two fixes:
|
||||
If the entity for your hostname doesn't exist yet (in `planned` or
|
||||
`provisioning` state), enrollment 4xxs. Two fixes:
|
||||
|
||||
- **Rename the host**: `sudo hostnamectl set-hostname <inventory-name>`
|
||||
(Linux) or System Preferences → Sharing (macOS), then re-run.
|
||||
- **Rename the inventory entry**: edit `inventory.yaml` on hubris,
|
||||
update `inventory.yaml`, push. The next sync (≤5 min) propagates.
|
||||
- **Rename the host** to match an existing planned entity:
|
||||
`sudo hostnamectl set-hostname <inventory-name>` (Linux) or System
|
||||
Preferences → Sharing (macOS), then re-run.
|
||||
- **Add/rename the inventory entry**: edit `seeds/inventory.yaml`, ingest
|
||||
via `oikos seed` (or the equivalent MCP/API entity-creation path), then
|
||||
re-run bootstrap.
|
||||
|
||||
### Getting onto Netbird
|
||||
### Networking prerequisites (Netbird, DNS, SSH key distribution)
|
||||
|
||||
Bootstrap auto-installs netbird and drives `netbird up` if the mesh isn't already connected (since commit `<bootstrap-tier1>`). Both paths below produce the same end state: `netbird status` shows `Management: Connected`, peer IP `100.122.x.x/16`.
|
||||
|
||||
**Path B — interactive OIDC (default; recommended):**
|
||||
|
||||
The new client runs bootstrap straight from a fresh OS. Bootstrap installs netbird (apt/dnf/brew based on the OS), then runs `netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400`. A device-code URL prints inline. The operator opens it (in a browser logged into Authentik), goes through identification → password → consent, and the CLI returns `Connected`. Bootstrap then proceeds with the rest of preflight.
|
||||
|
||||
Pre-condition: the operator must be a registered user in Authentik (typically the lab owner). The first user-login against a netbird account with existing peers is added as `pending_approval=1` and needs an sqlite promotion to `owner` — see [124-authentik.md First-time owner promotion gotcha](../../archive/knowledge/containers/106-auth-outpost.md). Only needed once per account.
|
||||
|
||||
**Path A — setup-key (headless/scripted onboarding):**
|
||||
|
||||
Useful for headless servers (no browser at all) or unattended cloud-init bootstraps.
|
||||
|
||||
1. From an already-enrolled machine, log into the dashboard at `https://netbird.hubris.network/`.
|
||||
2. **Setup Keys** → Create → set reusable + expiry → copy.
|
||||
3. On the new client (after installing netbird, OR let bootstrap install it and skip its `netbird up` driver):
|
||||
```bash
|
||||
sudo netbird up --setup-key <KEY> \
|
||||
--management-url https://netbird.hubris.network \
|
||||
--ssh-jwt-cache-ttl 86400
|
||||
```
|
||||
|
||||
**Why we can't OIDC-login from the public internet (still open as a follow-up):**
|
||||
|
||||
`auth.hubris.network` resolves publicly to the VPS (`82.165.190.79`), but Traefik on the VPS doesn't currently route that hostname — only `netbird.hubris.network` is exposed. A brand-new client *off the mesh* hitting `auth.hubris.network` directly gets a Traefik default 404. In practice, Path B works fine because the operator's BROWSER (which clicks the device-code URL) is usually on a network that can reach Authentik through the public IONOS IP via some path. But "fresh laptop in a coffee shop with no prior session anywhere" still gets stuck. Future-session fix: add a Traefik route on the VPS forwarding `auth.hubris.network` via the netbird-routed `192.168.8.0/24` to LXC 124.
|
||||
|
||||
### DNS prerequisite
|
||||
|
||||
`*.hubris.network` resolves via the split-horizon dnsmasq on LXC 124
|
||||
([dns.md](../../archive/knowledge/infrastructure/dns.md)) for LAN clients, **but only if the
|
||||
client uses 192.168.8.180 as its resolver**. Most LXCs and roaming
|
||||
workstations don't by default. Options:
|
||||
|
||||
- **LAN client**: set DNS to 192.168.8.180 (per-interface or
|
||||
`/etc/resolv.conf`).
|
||||
- **Off-LAN workstation on Netbird**: configure Netbird DNS forwarder to
|
||||
point `*.hubris.network` at LXC 124.
|
||||
- **Hack-fix anywhere**: append to `/etc/hosts`:
|
||||
```
|
||||
192.168.8.175 mcp.hubris.network secrets.hubris.network
|
||||
192.168.8.175 git.hubris.network
|
||||
```
|
||||
(192.168.8.175 = caddy on LXC 121, terminates all `*.hubris.network`.)
|
||||
|
||||
If DNS isn't an option at all, override the URLs at bootstrap time:
|
||||
|
||||
```bash
|
||||
sudo HOMELAB_GITEA_TOKEN=... \
|
||||
HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/oikos.git \
|
||||
HOMELAB_ISSUANCE_NETBIRD=http://192.168.8.205:9820/issue \
|
||||
HOMELAB_MCP_URL=http://192.168.8.205:9810/mcp \
|
||||
bash /tmp/bootstrap.sh --with-mcp
|
||||
```
|
||||
|
||||
## Install dependencies
|
||||
|
||||
Bootstrap auto-installs missing prerequisites (`git`, `python3` + PyYAML, `age`, `sops`, `netbird`) on Fedora/RHEL/Debian/Ubuntu/macOS — no manual `apt`/`dnf`/`brew` needed before running it. The only thing you must have on hand BEFORE the `curl ... | sudo bash` line is `curl` itself (used to pipe the script).
|
||||
|
||||
Manual install is still possible (e.g. for air-gapped or unusual platforms); the per-OS recipes are below for reference but optional.
|
||||
|
||||
<details>
|
||||
<summary>Manual recipes (Fedora / Debian / macOS)</summary>
|
||||
|
||||
```bash
|
||||
# Fedora / RHEL / Nobara
|
||||
sudo dnf install -y git python3-pyyaml age curl
|
||||
SOPS_VERSION=v3.9.4
|
||||
sudo curl -fsSL https://github.com/getsops/sops/releases/download/$SOPS_VERSION/sops-$SOPS_VERSION.linux.amd64 \
|
||||
-o /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
|
||||
|
||||
# Debian / Ubuntu
|
||||
sudo apt update && sudo apt install -y git python3-yaml age curl
|
||||
SOPS_VERSION=v3.9.4
|
||||
sudo curl -fsSL https://github.com/getsops/sops/releases/download/$SOPS_VERSION/sops-$SOPS_VERSION.linux.amd64 \
|
||||
-o /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
|
||||
|
||||
# macOS
|
||||
brew install git age sops
|
||||
pip3 install pyyaml # if `python3 -c "import yaml"` fails
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Run the bootstrap
|
||||
|
||||
You need a Gitea read-only personal access token for the initial clone
|
||||
(the in-cluster shared PAT is encrypted at `secrets/gitea-readonly-pat.yaml`
|
||||
but a new client can't decrypt it before bootstrap — chicken-and-egg).
|
||||
Ask the operator (or generate in Gitea: Settings → Applications → Generate
|
||||
New Token → scope `read:repository`).
|
||||
|
||||
```bash
|
||||
TOKEN=... # your Gitea PAT, scope read:repository
|
||||
|
||||
# Fetch bootstrap.sh from gitea (HTTPS uses split-DNS → caddy).
|
||||
curl -fsSL -u "dtoro:$TOKEN" \
|
||||
https://git.hubris.network/dtoro/oikos/raw/branch/main/bootstrap.sh \
|
||||
-o /tmp/bootstrap.sh
|
||||
|
||||
# Run it.
|
||||
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
| Flag | Effect |
|
||||
| --- | --- |
|
||||
| `--with-mcp` | Merges the homelab MCP server into `~/.claude/.mcp.json` of the invoking user |
|
||||
| `--no-secrets` | Skips age-key issuance (use when bringing up the first hosts before secrets-issuance exists) |
|
||||
| `--dry-run` | Prints actions without executing |
|
||||
|
||||
The bootstrap is idempotent: re-running on an enrolled client just
|
||||
verifies state, re-issues the age key only if it doesn't match the
|
||||
inventory pubkey, and refreshes the sync timer + symlinks.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
homelab whoami # prints hosts/$(hostname).yaml
|
||||
homelab list # shows the full topology
|
||||
homelab status # ping + HTTP-check across hosts/services
|
||||
homelab secret hello # decrypt the bootstrap-test secret
|
||||
systemctl list-timers homelab-context-sync.timer
|
||||
# next run within ≤5 min
|
||||
```
|
||||
|
||||
For Claude Code: start a new session — the `homelab` MCP server appears
|
||||
in `~/.claude/.mcp.json` and registers 14 tools (8 context, 5 management,
|
||||
1 secrets-metadata).
|
||||
|
||||
## Post-bootstrap: SSH reachability
|
||||
|
||||
A new workstation must be reachable from other workstations and must be
|
||||
able to reach every host by short hostname. Run these steps after the
|
||||
bootstrap verify passes:
|
||||
|
||||
### 1. Enable SSH server
|
||||
|
||||
```bash
|
||||
# macOS:
|
||||
sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist
|
||||
|
||||
# Linux:
|
||||
sudo systemctl enable --now sshd
|
||||
```
|
||||
|
||||
### 2. Generate SSH key (if missing)
|
||||
|
||||
```bash
|
||||
ls ~/.ssh/id_ed25519.pub 2>/dev/null || ssh-keygen -t ed25519 -a 100
|
||||
```
|
||||
|
||||
### 3. Publish pubkey to the repo
|
||||
|
||||
```bash
|
||||
cp ~/.ssh/id_ed25519.pub /opt/homelab-context/ssh/authorized_keys/$(hostname -s).pub
|
||||
cd /opt/homelab-context && git add ssh/authorized_keys/ && git commit -m 'ssh: add $(hostname -s) pubkey' && git push
|
||||
```
|
||||
|
||||
### 4. Deploy keys to all hosts
|
||||
|
||||
From any existing enrolled machine (hubris or another workstation):
|
||||
|
||||
```bash
|
||||
ssh root@192.168.8.77 "cd /opt/homelab-context && git pull --ff-only && bash ssh/deploy-keys.sh"
|
||||
```
|
||||
|
||||
This adds the new workstation's pubkey to hubris and every running LXC.
|
||||
|
||||
### 5. Generate SSH config
|
||||
|
||||
```bash
|
||||
homelab ssh-config --install
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
ssh hubris hostname # should return "hubris" without password
|
||||
ssh gitea hostname # should return "gitea" without password
|
||||
ssh mac-mini hostname # should return "mac-mini" without password (workstation-to-workstation)
|
||||
```
|
||||
|
||||
### 6. Add LAN IP to inventory (if on LAN)
|
||||
|
||||
If the workstation has a static or reserved LAN IP, add it to
|
||||
`inventory.yaml`:
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
your-hostname:
|
||||
lan_ip: 192.168.8.xxx
|
||||
```
|
||||
|
||||
This gives it a primary LAN entry in the generated SSH config (faster
|
||||
than the Netbird fallback). Commit + push, then:
|
||||
|
||||
```bash
|
||||
cd /opt/homelab-context && git pull --ff-only && homelab ssh-config --install
|
||||
```
|
||||
Migrated to a runbook in the knowledge base — query
|
||||
`search_knowledge("netbird mesh dns")` or `get_entity_knowledge`, or ask
|
||||
Nomos. Covers: getting onto the Netbird mesh (interactive OIDC vs.
|
||||
setup-key), why OIDC login can fail from off-mesh, split-horizon DNS
|
||||
options, and distributing a new workstation's SSH pubkey across the fleet
|
||||
via `ssh/deploy-keys.sh`.
|
||||
|
||||
## Claude Code permissions for fleet ops
|
||||
|
||||
By default Claude Code's auto-mode classifier asks for confirmation on every
|
||||
ssh into the mesh. The bootstrap already installs the ssh ControlMaster block
|
||||
so subsequent in-session sshes multiplex, but the *first* ssh of each session
|
||||
still gets classifier-evaluated. Pre-authorize the common fleet ssh patterns
|
||||
by adding to `~/.claude/settings.json`:
|
||||
ssh into the mesh. Pre-authorize the common fleet ssh pattern by adding to
|
||||
`~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"defaultMode": "auto",
|
||||
"allow": [
|
||||
"Bash(ssh -p 22022 *)",
|
||||
"Bash(homelab *)"
|
||||
"Bash(ssh -p 22022 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The first rule covers any ssh to a mesh peer on the homelab netbird port; the
|
||||
second covers all `homelab` CLI invocations. Both are scoped tight enough that
|
||||
the classifier doesn't gate them but loose enough to handle the variety of
|
||||
arguments.
|
||||
This covers any ssh to a mesh peer on the homelab netbird port, scoped tight
|
||||
enough that the classifier doesn't gate it but loose enough to handle the
|
||||
variety of arguments.
|
||||
|
||||
If you also want the netbird `--ssh-jwt-cache-ttl` flag rationale to be
|
||||
visible to the classifier (it's not actually durable in 0.71.2, but the
|
||||
ControlMaster block is — see [runbook-dpkg-interrupted](../skills/runbook-dpkg-interrupted/SKILL.md)
|
||||
for context), drop a free-text rule into `autoMode.allow` describing the
|
||||
authorization. Optional.
|
||||
## Open questions (not verified against current architecture — don't
|
||||
guess these from the old flow)
|
||||
|
||||
## Adding a new client to inventory
|
||||
The old two-step ceremony had answers for these; the current one-shot
|
||||
`/api/v1/clients/enroll` flow may handle them differently and this hasn't
|
||||
been re-verified:
|
||||
|
||||
If the hostname you want isn't yet in inventory, enrollment is a two-step
|
||||
ceremony driven from an existing enrolled client (e.g. hubris). The
|
||||
`homelab` CLI handles steps 1 + 4; you provide steps 2 + 3.
|
||||
|
||||
```bash
|
||||
# 1. On hubris (or any existing client): add the inventory entry.
|
||||
homelab client add my-new-machine
|
||||
# Prompts for kind, os, netbird FQDN, role. Commits + pushes.
|
||||
|
||||
# 2. Join the new machine to Netbird (out-of-band, Netbird console / setup key).
|
||||
|
||||
# 3. On the new machine: install deps + run bootstrap (above).
|
||||
# Bootstrap calls /issue, receives a fresh age keypair, and prints the
|
||||
# public key for the operator to commit back to inventory.
|
||||
|
||||
# 4. On hubris: finalize the age public key.
|
||||
homelab client add my-new-machine --finalize-pubkey age1...
|
||||
# Updates inventory.yaml hosts.my-new-machine.age_pubkey, regenerates
|
||||
# inventory.yaml, commits + pushes. The 5-min sync propagates.
|
||||
```
|
||||
|
||||
## Granting a secret to a new client
|
||||
|
||||
Adding a client doesn't grant them every secret. Recipients are explicit
|
||||
per file via `.sops.yaml` glob rules. To grant a client access to (say)
|
||||
`secrets/hello.yaml`:
|
||||
|
||||
1. Edit `.sops.yaml` at the repo root, add the client's `age_pubkey` to
|
||||
the matching `creation_rules` block.
|
||||
2. Re-key the existing ciphertext for the new recipient list:
|
||||
```bash
|
||||
sops updatekeys -y secrets/hello.yaml
|
||||
```
|
||||
3. Commit + push. On the next sync (≤5 min), the client can decrypt.
|
||||
|
||||
## Removing a client
|
||||
|
||||
```bash
|
||||
# From any existing client:
|
||||
homelab client remove my-old-machine
|
||||
```
|
||||
|
||||
This:
|
||||
1. Removes the inventory entry and `hosts/my-old-machine.yaml`.
|
||||
2. Runs `sops updatekeys -y` against every file in `secrets/` (operator
|
||||
must first remove the pubkey from `.sops.yaml` rules).
|
||||
3. Calls `secrets-issuance` `/revoke` (admin-token-gated, on LXC 105) to
|
||||
shred the key file and add the hostname to the denylist.
|
||||
4. Commits + pushes.
|
||||
|
||||
The CLI prints a follow-up checklist that the operator must do manually:
|
||||
|
||||
- Revoke the peer in the Netbird console (denies future mesh access).
|
||||
- **Rotate any credentials whose ciphertext the removed client already
|
||||
has on disk.** The age key revocation only protects *future*
|
||||
ciphertext; what's already been pulled is still decryptable until the
|
||||
underlying credential changes.
|
||||
- Optional: `homelab nuke my-old-machine` SSHes in, shreds
|
||||
`/etc/age/key.txt`, removes `/opt/homelab-context`, disables sync.
|
||||
- **Removing a client.** No current equivalent confirmed for the old
|
||||
`homelab client remove` (inventory removal + secret re-keying + key
|
||||
revocation). Likely maps to an entity lifecycle transition
|
||||
(`.agents/skills/lifecycle-deprecate-node/` or `lifecycle-destroy-node/`)
|
||||
but those skills reference the same dead CLI and need their own check.
|
||||
- **Granting a secret to an already-enrolled client.** The old flow
|
||||
hand-edited `.sops.yaml` `creation_rules` + `sops updatekeys`. Given
|
||||
Infisical is now the primary secrets backend (SOPS is the DR fallback),
|
||||
the current mechanism is probably Infisical-side, not a `.sops.yaml` edit
|
||||
— not confirmed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `no hosts/<hostname>.yaml in the repo` | Hostname doesn't match inventory entry | Rename either side (see above) |
|
||||
| `fatal: could not read Username for 'http://192.168.8.121:3000'` | bootstrap.sh's credentials file has wrong scheme | Fixed in commit `de6f8be`; pull latest `bootstrap.sh` |
|
||||
| `gnutls_handshake() failed: TLS connection was non-properly terminated` cloning `git.hubris.network` | Client DNS resolves `*.hubris.network` to the public VPS IP | Configure split-DNS (LXC 180 / Netbird forwarder) or `/etc/hosts` override; or use `HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/oikos.git` |
|
||||
| `TLS/SSL connection has been closed (EOF)` connecting MCP | Same — `mcp.hubris.network` resolves to public VPS without this vhost | Same DNS fix |
|
||||
| `Invalid Host header` from MCP server | FastMCP's DNS-rebinding protection (default whitelist is 127.0.0.1 only) | Fixed in commit `6848640`; pull latest `mcp/server.py` and redeploy |
|
||||
| `python3-yaml` install fails on Fedora | Wrong package name | Use `python3-pyyaml` (Fedora) instead of `python3-yaml` (Debian) |
|
||||
| `address already in use` for FastMCP | FastMCP defaults to 127.0.0.1:8000 | Fixed: server now sets `mcp.settings.host/port` from env (default `0.0.0.0:9810`) |
|
||||
| `homelab: no age key at /etc/age/key.txt` even after bootstrap | `/etc/age` is 0700 root, so non-root users couldn't even stat the key file; existence check returned False under regular users | Fixed in commit `df6aca8`: the CLI re-execs `sops -d` via sudo when invoked as a non-root user. On older deployments, re-link the CLI with `sudo ln -sfn /opt/homelab-context/bin/homelab /usr/local/bin/homelab` after the 5-min sync. |
|
||||
| `homelab` CLI doesn't pick up repo updates | Pre-`02db…` bootstrap copied the binary instead of symlinking | One-time migration: `sudo ln -sfn /opt/homelab-context/bin/homelab /usr/local/bin/homelab`. New bootstraps use the symlink, which auto-tracks the synced repo. |
|
||||
| `homelab-context-sync.service` journal shows `fatal: could not read Username for 'https://git.hubris.network'` | Pre-fix bootstrap set the gitea credential helper via `git config --global`, which writes to `/root/.gitconfig` — invisible to the systemd timer's git process (no HOME set). | One-time migration: `sudo git config --system credential.helper "store --file=/etc/homelab-context/git-credentials"`. New bootstraps store the helper in `/etc/gitconfig` instead. |
|
||||
| Enrollment 404s / entity not found | Hostname doesn't match a `planned`/`provisioning` inventory entry | See "Hostname mismatch" above |
|
||||
| `gnutls_handshake() failed` / TLS errors reaching `*.hubris.network` | Client DNS resolves `*.hubris.network` to the public VPS instead of the LAN/mesh path | See the networking runbook (split-horizon DNS section) |
|
||||
| Chat-mode `!` shell can't `sudo` (`a terminal is required to read the password`) | Claude Code's `!` invocation doesn't allocate a tty, and standard `sudo` won't read its password from stdin or a non-tty pipe. | Run the sudo'd command in a real terminal outside chat. For commands the agent issues repeatedly, configure passwordless sudo for the narrow set (e.g. `/etc/sudoers.d/homelab-self` with `<user> ALL=(ALL) NOPASSWD: /usr/bin/dnf upgrade -y, /usr/bin/apt-get *`). |
|
||||
| `netbird status -d` reports `192.168.8.180:53 ... is Unavailable` but DNS actually works | netbird's UDP-53 probe times out over the relay latency (~90ms), but actual queries still flow through systemd-resolved. Cosmetic. | Ignore unless `dig @192.168.8.180 git.hubris.network` also fails — then check dnsmasq on [LXC 124](../../archive/knowledge/containers/106-auth-outpost.md). |
|
||||
| `netbird ssh` rejected with `JWT authentication failed: validate token (expected issuer=https://netbird.hubris.network/oauth2 ...)` | Peer's SSH JWT validator cached the OLD embedded-Dex issuer from before the 2026-05-21 Authentik migration. `systemctl restart netbird` and `netbird down/up` don't clear it — `client/internal/engine_ssh.go` bails out of `updateSSH()` if the SSH server is already running. | Full daemon bounce: `sudo systemctl stop netbird; sleep 3; sudo systemctl start netbird`. Verify with `grep -iE "issuer\|audience" /var/log/netbird/client.log \| tail`. Apply once per peer post-migration. |
|
||||
| `netbird ssh` JWT passes but session closes with `user privilege check failed: user dtoro not found: unknown user dtoro` | netbird-ssh defaults the remote username to the LOCAL one (operator's laptop user). Hubris and LXCs only have `root`. | Always use explicit `root@` prefix manually: `netbird ssh -p 22022 root@proxmox-server.netbird.selfhosted`. `homelab ssh <host>` does this automatically via `inventory.yaml`'s per-host `ssh.user` field (defaults to `root`). |
|
||||
| `homelab ssh hubris` (or any host on the LAN) fails with `Connection refused` or hangs, despite mesh routing being up | Off-LAN networks (operator on a VPN / coffee shop / symmetric NAT) sometimes can't reach the LAN IP even with the netbird subnet route. | Newer homelab CLIs probe the LAN with a 1.5s TCP connect and transparently fall back to the netbird FQDN. If your `/usr/local/bin/homelab` is a symlink to `/opt/homelab-context/bin/homelab` it'll pick up the fix on the next 5-min context sync. Otherwise pull the latest from gitea. |
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-02 — SSH reachability post-bootstrap steps
|
||||
Added a new "Post-bootstrap: SSH reachability" section covering SSH key
|
||||
generation, pubkey publication, deployment to hosts, SSH config generation,
|
||||
and LAN IP registration. New workstations enrolled via this doc will
|
||||
automatically join the universal SSH mesh.
|
||||
### 2026-05-31 — cross-link to nomos-agent.md
|
||||
### 2026-07-12 — trimmed to current architecture
|
||||
Removed everything describing the retired `homelab` CLI-based two-step
|
||||
enrollment ceremony (now: `CLIENTS.md`'s one-shot flow), the Nous-Hermes/
|
||||
Goose cross-link (that whole flow was removed the same day), and CLI-syntax
|
||||
troubleshooting rows with no current equivalent. Migrated the still-true
|
||||
Netbird/DNS/SSH-distribution content to a knowledge-base runbook rather
|
||||
than duplicating it here. What's left is genuinely current or explicitly
|
||||
flagged as unverified. Original ~365-line version is in git history
|
||||
(`git log -- .agents/operations/agent-enrollment.md`) if any of the removed
|
||||
detail turns out to still be needed.
|
||||
|
||||
Added a sibling page covering Nous-Hermes-on-Goose enrollment ([nomos-agent.md](nomos-agent.md)) and noted it at the top of this page. The Nomos flow extends `bootstrap.sh` with `--with-nomos` and `homelab client add` with the same flag; it does not change the underlying enrollment steps documented here.
|
||||
### 2026-06-02 — SSH reachability post-bootstrap steps
|
||||
Added a section covering SSH key generation, pubkey publication,
|
||||
deployment to hosts, SSH config generation, and LAN IP registration. New
|
||||
workstations enrolled via this doc automatically join the SSH mesh.
|
||||
(Superseded 2026-07-12 — migrated to the networking runbook.)
|
||||
|
||||
### 2026-05-31 — cross-link to nomos-agent.md
|
||||
Added a sibling page covering Nous-Hermes-on-Goose enrollment. (Removed
|
||||
2026-07-12 along with the rest of that flow.)
|
||||
|
||||
### 2026-05-21 — netbird-ssh JWT issuer + username + LAN-fallback troubleshooting rows
|
||||
Added three rows to the troubleshooting table covering issues surfaced during the netbird vanilla migration: (1) post-migration SSH JWT validator cache stuck on old Dex issuer (full `systemctl stop/start` required, not `restart`), (2) `user not found` from netbird-ssh's local-username default (use explicit `root@`), and (3) homelab CLI's LAN→netbird-FQDN fallback for off-LAN operators. Companion code change: per-host `ssh.user` field in `inventory.yaml` + `homelab` CLI's `ssh_target()` helper.
|
||||
Added three rows to the troubleshooting table covering issues surfaced
|
||||
during the netbird vanilla migration. (Migrated 2026-07-12 to the
|
||||
networking runbook.)
|
||||
|
||||
### 2026-05-20 — initial page
|
||||
Captures the enrollment flow validated during Phase 2 of the homelab
|
||||
|
||||
@@ -53,34 +53,36 @@ Run from the [hubris host](../../archive/knowledge/hosts/hubris.md) as root. Whe
|
||||
|
||||
## Fleet apt operations
|
||||
|
||||
Two `homelab` subcommands wrap the common patterns; both fan out to hubris + every LXC.
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `homelab apt-audit [--target HOST]` | Per-host table: dpkg-interrupted state, holds, upgradable count, non-apt binaries in system paths, DNS health. Exits nonzero if any host has dpkg-interrupted state. |
|
||||
| `homelab apt-upgrade --target HOST` | Launch `apt update && apt upgrade` inside a transient `systemd-run --collect` unit on the target. Survives ssh teardown. Apt configured with `Acquire::Retries=3` + `ForceIPv4=true`. |
|
||||
| `homelab apt-upgrade --all` | Same, fanned out across the standard targets. |
|
||||
| `homelab apt-upgrade ... --status` | Show running unit + tail `/var/log/homelab-apt-upgrade.log` on each target. |
|
||||
| `homelab apt-upgrade ... --safe` | Take a pre-upgrade snapshot per LXC first (`pct snapshot` → `vzdump` fallback for bind-mounted LXCs). Refuses if any snapshot fails unless `--force`. |
|
||||
| `homelab apt-upgrade ... --force` | Skip both the dpkg-audit gate and snapshot-failure refusal. |
|
||||
|
||||
PVE/kernel deferral on hubris: `homelab apt-upgrade --target hubris` will try every upgrade, including kernel + `pve-*`. To skip those, `apt-mark hold` the relevant packages on hubris first; `homelab apt-audit` shows held packages so you can confirm.
|
||||
**No current CLI equivalent.** `homelab apt-audit`/`apt-upgrade` (dpkg-state
|
||||
audit, fanned-out apt upgrade with pre-upgrade snapshots) were part of the
|
||||
retired Python `homelab` CLI and don't have a ported replacement — apt
|
||||
patching today is ad hoc `run` MCP tool calls per host, without the
|
||||
audit/snapshot/status wrapping this used to provide. If that wrapping is
|
||||
still wanted, it needs to be rebuilt (e.g. as a runbook driving `run`, or a
|
||||
new MCP tool) — see
|
||||
[runbook-dpkg-interrupted](../skills/runbook-dpkg-interrupted/SKILL.md) for
|
||||
the dpkg-interrupted recovery procedure specifically.
|
||||
|
||||
## Oikos (agent OS layer)
|
||||
|
||||
See [OIKOS.md](../OIKOS.md) for the operating model. Quick reference:
|
||||
See [OIKOS.md](../OIKOS.md) for the operating model. The `homelab` CLI this
|
||||
section used to document is retired; the actual current interface is the
|
||||
33 MCP tools cataloged in [AGENTS.md](../../AGENTS.md#3-the-mcp-server) plus
|
||||
the REST API. Closest current equivalents for what used to live here:
|
||||
|
||||
| Command | What it does |
|
||||
| Old `homelab` command | Current equivalent |
|
||||
| --- | --- |
|
||||
| `homelab service <name> explain\|health\|docs\|log\|actions\|history` | Service Console v0 — context card, cached health (`--live` to force a probe), docs, logs, safe actions + risk class, ledger history |
|
||||
| `homelab node <name> relations` | Ontology blast-radius query: what this host/service impacts, is affected by, and its full transitive blast radius |
|
||||
| `homelab change preflight <service>` | Dry-run report before mutating: risk class, current health, config repo, verification command |
|
||||
| `homelab decide <action> <entity>` | Decision classifier: risk × blast radius × confidence → auto-act or escalate |
|
||||
| `homelab signal list\|raise\|ack\|resolve\|mute` | The attention layer — pending updates, thresholds, drift, anything needing attention |
|
||||
| `homelab approval request\|list\|reply\|check` | Escalate-route grants (Matrix-delivered via Nomos, or the Oikos Console's `/approvals` page) |
|
||||
| `homelab restart <service> [--approval-id <id>]` | `--approval-id` is required whenever the service's risk class needs approval (e.g. `caddy`, `dns`) — refuses mechanically without a valid grant |
|
||||
| `homelab service <name> explain\|health\|docs\|log` | MCP `explain`, `get_service_status`, `tail_log`, `get_entity_knowledge` |
|
||||
| `homelab node <name> relations` | MCP `get_blast_radius` |
|
||||
| `homelab change preflight <service>` | MCP `preflight` |
|
||||
| `homelab signal list\|ack\|resolve\|mute` | MCP `get_signal_history`, or REST `POST /api/v1/signals/{id}/ack\|resolve\|mute` (the control-room UI's Signals page wraps these) |
|
||||
| `homelab approval request\|list\|reply\|check` | REST `GET/POST /api/v1/approvals*` (Matrix-delivered via the notifier, or the control-room UI's Operations page) |
|
||||
| `homelab restart <service> --approval-id <id>` | MCP `run` (policy-gated — auto-executes if read-only/reversible_low, otherwise queues for the same Matrix/UI approval) |
|
||||
| `homelab decide <action> <entity>` | No direct equivalent — classification now happens inline inside `run`/`request_execution`, not as a separate dry-run call |
|
||||
|
||||
Oikos Console (read-mostly dashboard): `oikos.hubris.network` once deployed — see [oikos/console/deploy/README.md](../../archive/oikos-cards/).
|
||||
There is no separately-deployed "Oikos Console" anymore — the control-room
|
||||
SPA (`web/`) is the operator dashboard, served standalone (see
|
||||
[plans/2026-07-12-wails-desktop-app.md](../../plans/2026-07-12-wails-desktop-app.md)).
|
||||
|
||||
## Related
|
||||
- [Hubris host](../../archive/knowledge/hosts/hubris.md)
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
# Nomos agent — LLM-powered terminal sessions on a homelab client
|
||||
|
||||
Onboards [Nous Research's Hermes](https://nousresearch.com/) (a fine-tuned
|
||||
Llama variant) as a working terminal agent on a homelab client. Builds on top
|
||||
of standard client enrollment (see [agent-enrollment.md](agent-enrollment.md))
|
||||
— this page covers only the Hermes-specific additions.
|
||||
|
||||
The agent runs as a [Goose](https://goose-docs.ai/) session. Goose provides:
|
||||
|
||||
- The chat loop, multi-turn history, and streaming
|
||||
- The OpenRouter provider that routes to the configured LLM
|
||||
- The built-in `developer` extension (shell + file editor — same surface Claude
|
||||
Code has)
|
||||
- A remote MCP extension pointed at `mcp.hubris.network` for read-only
|
||||
homelab context (`list_lxcs`, `tail_log`, `search_docs`, etc.)
|
||||
|
||||
The persona is `/opt/homelab-context/NOMOS.md`, symlinked as Goose's global
|
||||
`.goosehints` so it's injected into the system prompt on every session.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | How |
|
||||
| --- | --- |
|
||||
| Standard enrollment complete (`homelab whoami` works) | [agent-enrollment.md](agent-enrollment.md) |
|
||||
| `secrets/openrouter-api-key.yaml` exists with a real `sk-or-...` value | See "Seeding the OpenRouter key" below |
|
||||
| The host's `age_pubkey` is on the openrouter-api-key.yaml sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-nomos` |
|
||||
|
||||
## Onboarding flow
|
||||
|
||||
```bash
|
||||
# 1. On hubris (or any enrolled client): reserve the inventory entry.
|
||||
homelab client add new-machine
|
||||
|
||||
# 2. Join new-machine to Netbird (setup-key or OIDC).
|
||||
|
||||
# 3. On new-machine: bootstrap with --with-nomos.
|
||||
TOKEN=... # gitea PAT, read:repository
|
||||
curl -fsSL -u "dtoro:$TOKEN" \
|
||||
https://git.hubris.network/dtoro/oikos/raw/branch/main/bootstrap.sh \
|
||||
-o /tmp/bootstrap.sh
|
||||
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp --with-nomos
|
||||
|
||||
# 4. Back on hubris: finalize the age pubkey AND grant the Nomos secret.
|
||||
homelab client add new-machine \
|
||||
--finalize-pubkey age1... \
|
||||
--with-nomos
|
||||
|
||||
# 5. Wait ≤5 min for sync, then on new-machine:
|
||||
nomos "what LXCs are running?"
|
||||
```
|
||||
|
||||
The bootstrap `--with-nomos` flag does five things, all idempotent:
|
||||
|
||||
1. Downloads the latest Goose binary into the operator's `~/.local/bin/goose`
|
||||
(upstream installer) and symlinks `/usr/local/bin/goose` to it.
|
||||
2. Symlinks `/opt/homelab-context/bin/nomos` → `/usr/local/bin/nomos`.
|
||||
3. Symlinks `/opt/homelab-context/NOMOS.md` → `/root/NOMOS.md` (Linux) or
|
||||
`/etc/NOMOS.md` (macOS) for `cat`-as-operator convenience.
|
||||
4. Drops `~/.config/goose/config.yaml` pinning the provider, model, and
|
||||
extensions (preserves any keys the operator added by hand).
|
||||
5. Symlinks `~/.config/goose/.goosehints` → NOMOS.md, so the persona is
|
||||
injected as the system prompt on every session.
|
||||
|
||||
## Seeding the OpenRouter key
|
||||
|
||||
The first time anyone enrolls with `--with-nomos`, the encrypted file
|
||||
`secrets/openrouter-api-key.yaml` contains a placeholder. On hubris (or any
|
||||
existing recipient):
|
||||
|
||||
```bash
|
||||
sops secrets/openrouter-api-key.yaml
|
||||
# editor opens; replace api_key value with the real sk-or-... key, save, close.
|
||||
git -C /opt/homelab-context add secrets/openrouter-api-key.yaml
|
||||
git -C /opt/homelab-context commit -m 'openrouter-api-key: seed real key'
|
||||
git -C /opt/homelab-context push
|
||||
```
|
||||
|
||||
Until this step happens, `nomos …` exits with `openrouter-api-key.yaml still
|
||||
contains the placeholder`. Subsequent enrollees get the real key automatically
|
||||
via `--with-nomos` (which adds them as a sops recipient on
|
||||
`secrets/openrouter-api-key.yaml`).
|
||||
|
||||
## Granting the OpenRouter key to an already-enrolled host
|
||||
|
||||
If a host was enrolled without `--with-nomos` and you want to add it later:
|
||||
|
||||
```bash
|
||||
# On hubris:
|
||||
PUBKEY=$(homelab whoami --hostname <host> | grep age_pubkey | awk '{print $2}')
|
||||
homelab client add <host> --finalize-pubkey "$PUBKEY" --with-nomos
|
||||
```
|
||||
|
||||
`--finalize-pubkey` is required by the existing flow even when the pubkey is
|
||||
unchanged — it's also the trigger that runs the sops grant.
|
||||
|
||||
After ≤5 min sync the host can decrypt the key. Bootstrap doesn't need to
|
||||
re-run; only the secret recipient list changed.
|
||||
|
||||
## Verifying
|
||||
|
||||
```bash
|
||||
homelab whoami # standard enrollment OK
|
||||
homelab secret openrouter-api-key | head -c 8 # decrypts (prints `api_key:`)
|
||||
which goose && which nomos # binaries present
|
||||
goose info -v # provider/model wiring sane
|
||||
nomos "what LXCs are running?" # interactive Goose session
|
||||
|
||||
# Non-interactive smoke test:
|
||||
echo "List the homelab MCP tools you have available" | nomos
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The bootstrap-managed keys in `~/.config/goose/config.yaml`:
|
||||
|
||||
```yaml
|
||||
GOOSE_PROVIDER: openrouter
|
||||
GOOSE_MODEL: deepseek/deepseek-v4-flash
|
||||
GOOSE_MODE: smart_approve # asks before destructive tool calls
|
||||
extensions:
|
||||
developer:
|
||||
type: builtin
|
||||
bundled: true
|
||||
enabled: true
|
||||
name: developer
|
||||
timeout: 300
|
||||
homelab:
|
||||
type: streamable_http
|
||||
enabled: true
|
||||
name: homelab
|
||||
uri: https://mcp.hubris.network/mcp
|
||||
timeout: 60
|
||||
```
|
||||
|
||||
Override via env on a single bootstrap run:
|
||||
|
||||
```bash
|
||||
HOMELAB_NOMOS_MODEL=nousresearch/hermes-3-llama-3.1-405b \
|
||||
HOMELAB_NOMOS_MCP_URI=https://mcp.hubris.network/mcp \
|
||||
sudo bash /tmp/bootstrap.sh --with-nomos
|
||||
```
|
||||
|
||||
Any keys you add by hand (e.g. `GOOSE_TEMPERATURE`, extra `extensions.*`) are
|
||||
preserved across re-bootstraps — the merge only overwrites the keys it manages.
|
||||
|
||||
## Tool permissions
|
||||
|
||||
`GOOSE_MODE: smart_approve` is the bootstrap default: Goose runs read-only
|
||||
shell commands without prompting and asks for confirmation before destructive
|
||||
ones. To make the agent fully unattended (e.g. for scheduled jobs), set
|
||||
`GOOSE_MODE: auto` in `~/.config/goose/config.yaml`. To require confirmation on
|
||||
every tool call, use `approve`. See
|
||||
[goose-permissions](https://goose-docs.ai/docs/guides/managing-tools/goose-permissions/).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `nomos: could not decrypt secrets/openrouter-api-key.yaml` | Host isn't a recipient on the sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-nomos` from hubris |
|
||||
| `nomos: openrouter-api-key.yaml still contains the placeholder` | No real key has been seeded yet | See "Seeding the OpenRouter key" above |
|
||||
| Goose hangs on first `nomos` invocation with no output | Goose's interactive `configure` ran on first launch and is awaiting input | Re-run; the installer is supposed to skip it (CONFIGURE=false). If it persists, run `goose configure` once manually in a real terminal to commit the config. |
|
||||
| `homelab` extension fails to connect / no MCP tools listed | MCP server upgraded in Go rewrite (`internal/mcp/server.go`, Streamable HTTP via official MCP SDK). Old FastMCP SSE transport is deprecated. | Run `docker compose --profile full up` on mac-mini, or wait for the production cutover from apps/105. |
|
||||
| `goose: command not found` after bootstrap | Upstream installer dropped binary in `~/.local/bin/` but `/usr/local/bin/goose` symlink didn't land | Re-run bootstrap with `--with-nomos`; the symlink step is at the end of the install block. If still missing, `ln -sfn ~/.local/bin/goose /usr/local/bin/goose` manually. |
|
||||
| Tool calls hit OpenRouter rate limits | One shared key across many hosts | Future: per-host keys; for now, see the rate-limits guide referenced in `goose info -v`. |
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [agent-enrollment.md](agent-enrollment.md) — base client onboarding the
|
||||
Nomos flow assumes is done.
|
||||
- [`NOMOS.md`](../NOMOS.md) — the persona the Nomos agent reads on every
|
||||
session start (via `~/.config/goose/.goosehints`).
|
||||
- [`bin/nomos`](../../bin/nomos) — the wrapper that decrypts the OpenRouter key
|
||||
and execs `goose session`.
|
||||
- [`bootstrap.sh`](../../bootstrap.sh) — the `--with-nomos` flag's install block.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
1. **Migrate the MCP server to streamable_http.** Goose 1.x deprecated SSE
|
||||
(`"SSE transport is no longer supported - kept only for config file
|
||||
compatibility"` in `crates/goose/src/agents/extension.rs`). Our FastMCP
|
||||
server at `internal/mcp/server.go` uses Streamable HTTP (official MCP SDK). Until
|
||||
that's changed, the `homelab` MCP extension in Goose will fail to connect.
|
||||
The developer extension (shell + edit) covers most ops without it; this is
|
||||
a polish item, not a blocker.
|
||||
2. **Per-host OpenRouter keys** for billing attribution. Today all Nomos
|
||||
hosts share one key.
|
||||
3. **Pin the model version** rather than tracking `nousresearch/hermes-4-405b`
|
||||
directly — OpenRouter periodically rotates the underlying weights.
|
||||
4. **Local-inference fallback** (ollama / vllm) once the homelab has a GPU
|
||||
node. The wrapper, persona, and MCP wiring stay unchanged; only
|
||||
`GOOSE_PROVIDER`/`GOOSE_MODEL` change.
|
||||
|
||||
7. **Caveman auto-setup via post-pull hook.** The sync timer now calls
|
||||
`tools/post-pull.sh`, which runs any `tools/*.setup.sh` after git pull.
|
||||
Currently this auto-installs the Caveman npm package, wrapper scripts, and
|
||||
compact output templates on all agent hosts (*token efficiency*).
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-06-01 — caveman + post-pull auto-setup
|
||||
Added `tools/post-pull.sh` sync hook that auto-runs `tools/*.setup.sh`
|
||||
after every git pull. First user: `tools/setup-caveman.sh` installed Caveman
|
||||
templating + `~/bin/caveman_wrapper.sh` + `~/templates/*.txt` for token-
|
||||
efficient CLI output. Replaces raw `git pull` in launchd/systemd timers.
|
||||
Also created `tools/caveman/` with the wrapper script, JS renderer, and
|
||||
templates — the canonical source for all agent hosts.
|
||||
Captures the Nomos-on-Goose onboarding flow added in the same commit as
|
||||
`bootstrap.sh --with-nomos`, `bin/nomos`, the sops rule for
|
||||
`secrets/openrouter-api-key.yaml`, and the `homelab client add --with-nomos`
|
||||
extension. MCP streamable_http migration is queued as follow-up #1.
|
||||
@@ -2,39 +2,46 @@
|
||||
name: client-enrollment
|
||||
risk_class: config_mutation
|
||||
inputs: [hostname, kind, role]
|
||||
verification: "homelab doctor (on the new client)"
|
||||
verification: "MCP whoami(hostname) shows the entity active"
|
||||
docs_update_checklist: [hosts_narrative_page_if_lxc_or_vm]
|
||||
---
|
||||
|
||||
# Client enrollment
|
||||
|
||||
Goal: bring a new host (workstation, LXC, VM) into inventory and the
|
||||
secrets model, with mesh membership only where it's actually needed.
|
||||
This wraps the existing `homelab client add` flow — see
|
||||
[operations/agent-enrollment.md](../../operations/agent-enrollment.md) for
|
||||
the full walkthrough; this runbook is the risk/lifecycle framing.
|
||||
secrets model, with mesh membership only where it's actually needed. See
|
||||
[CLIENTS.md](../../../CLIENTS.md#enrollment) for the actual current
|
||||
flow and [operations/agent-enrollment.md](../../operations/agent-enrollment.md)
|
||||
for operational notes; this runbook is the risk/lifecycle framing.
|
||||
|
||||
1. On any enrolled client: `homelab client add <hostname>` — appends a
|
||||
`hosts.<name>:` block to `inventory.yaml` (lifecycle `state: planned`
|
||||
→ `provisioning`, per [seeds/ontology.yaml](../../../seeds/ontology.yaml)),
|
||||
commits + pushes.
|
||||
1. The entity must exist in `planned`/`provisioning` state before the new
|
||||
host can self-enroll — add a `hosts.<name>:` block to
|
||||
`seeds/inventory.yaml` and `oikos seed` to ingest it (lifecycle
|
||||
`planned` → `provisioning`, per
|
||||
[seeds/ontology.yaml](../../../seeds/ontology.yaml)).
|
||||
2. Netbird join is **optional, not a required step** — only needed for
|
||||
hosts that must be reachable off-LAN (workstations that roam, e.g.
|
||||
`republic-laptop`, `mac-mini`). A node reachable on the household LAN
|
||||
(192.168.8.0/24 — most LXCs/VMs) doesn't need it: it's already
|
||||
reachable directly, and off-LAN clients reach it too via hubris's
|
||||
routed `192.168.8.0/24` Netbird network resource. Skip this step for
|
||||
LAN-only nodes; do it (out-of-band, console or setup key) only for
|
||||
hosts that need independent off-LAN reachability.
|
||||
3. On the new host: run `bootstrap.sh` (add `--with-nomos` to also
|
||||
enroll the Hermes agent). This provisions `/etc/age/key.txt`, the
|
||||
sync timer, and prints an age pubkey.
|
||||
4. Back on an enrolled client: `homelab client add <hostname>
|
||||
--finalize-pubkey <age1...>` — sets `age_pubkey`, grants shared
|
||||
secrets, re-keys SOPS, commits + pushes. This is the
|
||||
`provisioning → active` transition.
|
||||
5. Verify: `homelab doctor` on the new client should show all checks
|
||||
green (clone, sync timer, age key, CLI symlink, MCP reachable).
|
||||
`mac-mini`). A node reachable on the household LAN (192.168.8.0/24 —
|
||||
most LXCs/VMs) doesn't need it. Skip for LAN-only nodes; do it
|
||||
(out-of-band, console or setup key) only for hosts that need
|
||||
independent off-LAN reachability.
|
||||
3. On the new host: run `bootstrap.sh`. This calls
|
||||
`POST /api/v1/clients/enroll`, which validates the entity exists and
|
||||
the mesh IP is in an expected subnet, then returns an age keypair and
|
||||
Infisical machine identity in one response — provisions
|
||||
`/etc/age/key.txt`, `/etc/infisical/identity`, and the context poller.
|
||||
4. **Known gap, confirmed 2026-07-12: `provisioning → active` has no
|
||||
working path.** `EnrollClient` (`internal/httpapi/impl.go`) sets the
|
||||
entity's state to `provisioning`, never `active`. `bootstrap.sh` prints
|
||||
`POST /api/v1/clients/ws:$HNAME/activate` as the next step, but that
|
||||
route doesn't exist — `api/openapi.yaml` only has `/clients/enroll`,
|
||||
`/clients/{slug}/context`, `/clients/{slug}/secrets`. Until this is
|
||||
fixed (add the route, or use the generic entity PATCH to flip `state`),
|
||||
a freshly-enrolled client is stuck in `provisioning` — MCP `preflight`
|
||||
and policy's `lifecycle_overrides` for `provisioning` still apply, but
|
||||
nothing transitions it onward automatically.
|
||||
5. Verify: MCP `whoami(hostname)` shows the entity in `active` state with
|
||||
its peers and health.
|
||||
|
||||
Docs-update checklist: if the new host is an LXC/VM, add its narrative
|
||||
page under `containers/` or `vms/` and set `doc_page` in its inventory
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: config-change-deploy
|
||||
risk_class: config_mutation
|
||||
inputs: [service_name, change_description]
|
||||
verification: "curl -sf <service_url> (or homelab service <name> health)"
|
||||
verification: "curl -sf <service_url> (or MCP get_service_status)"
|
||||
docs_update_checklist: [doc_page, changelog]
|
||||
---
|
||||
|
||||
@@ -11,11 +11,10 @@ docs_update_checklist: [doc_page, changelog]
|
||||
Goal: change a tracked config repo (Caddy, Gitea customizations, an app's
|
||||
own repo) and get it live, safely.
|
||||
|
||||
1. `homelab change preflight <service>` — current health, the service's
|
||||
`config_repo`, its risk class, and the verification command to run
|
||||
after. If risk class requires approval (`config_mutation` or
|
||||
`destructive`), stop and get operator sign-off before editing — see
|
||||
`seeds/policy.yaml`.
|
||||
1. MCP `preflight` — current health, the service's `config_repo`, its
|
||||
risk class, and the verification command to run after. If risk class
|
||||
requires approval (`config_mutation` or `destructive`), stop and get
|
||||
operator sign-off before editing — see `seeds/policy.yaml`.
|
||||
2. Clone/pull the `config_repo` (never edit the backend's working tree
|
||||
directly — tracked configs change by commit + push, per
|
||||
[OIKOS.md](../../OIKOS.md) conventions).
|
||||
@@ -24,10 +23,10 @@ own repo) and get it live, safely.
|
||||
[infrastructure/auto-deploy.md](../../../archive/knowledge/infrastructure/auto-deploy.md) for
|
||||
the exact receiver/reload for this service).
|
||||
5. Run the preflight's verification command. If it fails, check
|
||||
`homelab service <name> log` for the reload/restart error.
|
||||
6. Record the change: once `oikos/ledger.py` is wired into deploy tooling
|
||||
(Week 3), this is automatic; until then, note the change and outcome
|
||||
in the relevant investigation/plan doc.
|
||||
MCP `tail_log` for the reload/restart error.
|
||||
6. No manual record-keeping step needed — mutations made through the API
|
||||
(e.g. via the `run` MCP tool) are recorded automatically in the
|
||||
`audit_log` table.
|
||||
|
||||
Docs-update checklist: update the service's `doc_page` if the change
|
||||
alters its behavior, ingress route, or ownership; add a changelog entry
|
||||
|
||||
@@ -10,22 +10,23 @@ docs_update_checklist: [investigations_entry]
|
||||
|
||||
Goal: understand what broke and why, before touching anything.
|
||||
|
||||
1. `homelab service <name> explain` (or `homelab node <name> relations`
|
||||
if the affected entity is a host) — get the blast radius and doc
|
||||
pointer first. Don't start pulling logs blind.
|
||||
2. `homelab service <name> health` + `homelab service <name> log` (or
|
||||
MCP `get_service_status` / `tail_log`) for the affected service.
|
||||
1. MCP `explain` (or `get_blast_radius` if the affected entity is a
|
||||
host) — get the blast radius and doc pointer first. Don't start
|
||||
pulling logs blind.
|
||||
2. MCP `get_service_status` + `tail_log` for the affected service.
|
||||
3. Walk the blast radius: is a shared dependency down (`caddy`, `dns`,
|
||||
`authentik`, or the backend host itself)? `homelab node <name>
|
||||
relations` shows "affected by" — check those first.
|
||||
4. `homelab apt-audit` if the symptom looks like a dpkg/upgrade
|
||||
interaction.
|
||||
`authentik`, or the backend host itself)? MCP `get_blast_radius`
|
||||
shows "affected by" — check those first.
|
||||
4. If the symptom looks like a dpkg/upgrade interaction, see
|
||||
[runbook-dpkg-interrupted](../runbook-dpkg-interrupted/SKILL.md) —
|
||||
there's no fleet-wide apt-audit tool anymore, check the host directly.
|
||||
5. Check the change ledger for recent mutations to the affected entity
|
||||
or anything upstream of it: `homelab service <name> history` (once
|
||||
populated) or grep `ledger/*.jsonl`.
|
||||
6. Write findings to a new `knowledge/sources/investigations/<date>-<slug>.md` — symptom,
|
||||
timeline, root cause, fix applied, prevention. This is the durable
|
||||
record; don't rely on chat history.
|
||||
or anything upstream of it: MCP `get_change_history` or `get_audit_trail`.
|
||||
6. Write findings via MCP `upsert_knowledge` (`kind: investigation`) —
|
||||
symptom, timeline, root cause, fix applied, prevention, `about` set to
|
||||
the affected entity's slug. The DB is the durable record now, not a
|
||||
markdown file — `search_knowledge`/`get_entity_knowledge` read it back;
|
||||
a chat message alone is forgotten.
|
||||
|
||||
Docs-update checklist: always create the investigation entry. If the
|
||||
root cause was stale/wrong inventory data (a `doc_page`, `config_repo`,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: lifecycle-activate-node
|
||||
risk_class: config_mutation
|
||||
inputs: [node_name]
|
||||
verification: "homelab service <name> health (if it hosts a service); homelab doctor (if it's a client)"
|
||||
verification: "MCP get_service_status (if it hosts a service); MCP whoami (if it's a client)"
|
||||
docs_update_checklist: [doc_page_complete]
|
||||
transition: "provisioning -> active"
|
||||
---
|
||||
@@ -14,23 +14,22 @@ enrolled if it needs secrets, mesh joined if it needs off-LAN reach,
|
||||
ingress live if public, health check answering, doc page complete,
|
||||
ledger entry.
|
||||
|
||||
1. If the node is a `homelab` client: finish enrollment per
|
||||
[client-enrollment.md](../client-enrollment/SKILL.md) (`--finalize-pubkey`,
|
||||
mesh join, `homelab doctor` green).
|
||||
1. If the node self-enrolls as a client: finish enrollment per
|
||||
[CLIENTS.md](../../../CLIENTS.md#enrollment) (`bootstrap.sh` →
|
||||
`/api/v1/clients/enroll`, mesh join, MCP `whoami` returns the entity).
|
||||
2. If it hosts a public service: add the `services:` entry in
|
||||
`inventory.yaml` (backend, url, doc_page, config_repo, risk_notes —
|
||||
see the Week-1 service contract fields) and wire the Caddy route in
|
||||
`dtoro/caddy-conf`.
|
||||
3. Confirm the health check answers: `homelab service <name> health` or
|
||||
a direct `curl`.
|
||||
`seeds/inventory.yaml` (backend, url, doc_page, config_repo,
|
||||
risk_notes) and wire the Caddy route in `dtoro/caddy-conf`.
|
||||
3. Confirm the health check answers: MCP `get_service_status` or a
|
||||
direct `curl`.
|
||||
4. Flip `state: provisioning` → `state: active` (or delete the `state:`
|
||||
field — `active` is the default) in `inventory.yaml`.
|
||||
field — `active` is the default) in `seeds/inventory.yaml`, then
|
||||
`oikos seed` to ingest.
|
||||
5. Complete the doc page (stub → full narrative: role, specs, how it's
|
||||
configured, dependencies).
|
||||
6. Record the activation: `oikos/ledger.py append host:<name> activate
|
||||
config_mutation --result ok` (or let the CLI wrapper do this once
|
||||
Week 3's runbook automation lands).
|
||||
6. No manual record-keeping step needed — the activation (via whatever
|
||||
API call flipped the state) is recorded automatically in `audit_log`.
|
||||
|
||||
Regenerate derived data: `python3 mcp/build_host_files.py && python3
|
||||
inventory.yaml` so `inventory.yaml`, the topology diagram, and
|
||||
the context card all reflect the new state.
|
||||
Regenerate: `oikos seed` re-ingests `seeds/inventory.yaml`; `oikos export`
|
||||
writes DB state back out to the YAML if you mutated via the API/MCP
|
||||
instead of editing the file directly.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: lifecycle-deprecate-node
|
||||
risk_class: config_mutation
|
||||
inputs: [node_name, replacement_node_or_reason]
|
||||
verification: "homelab node <name> relations — 'affected by' must be empty before completing"
|
||||
verification: "MCP get_blast_radius — 'affected by' must be empty before completing"
|
||||
docs_update_checklist: [doc_page_deprecation_note]
|
||||
transition: "active -> deprecated"
|
||||
---
|
||||
@@ -16,14 +16,14 @@ suggestion; `seeds/policy.yaml` `lifecycle_overrides.deprecated.refuse`
|
||||
lists `new-inbound-edges` as refused going forward.
|
||||
|
||||
1. Set `state: deprecated` on the node.
|
||||
2. `homelab node <name> relations` — read `affected_by`. Every entry
|
||||
there is something still relying on this node.
|
||||
2. MCP `get_blast_radius` — read `affected_by`. Every entry there is
|
||||
something still relying on this node.
|
||||
3. Migrate or retire each dependent one at a time (point its `backend`/
|
||||
`config_repo`/ingress route elsewhere, or deprecate it too if it's
|
||||
being retired alongside).
|
||||
4. Re-run `homelab node <name> relations` after each dependent is moved.
|
||||
The transition to `destroyed` is only safe once `affected_by` is
|
||||
empty — check this every time, don't assume from memory.
|
||||
4. Re-run MCP `get_blast_radius` after each dependent is moved. The
|
||||
transition to `destroyed` is only safe once `affected_by` is empty —
|
||||
check this every time, don't assume from memory.
|
||||
5. Note the deprecation on the doc page: reason, replacement (if any),
|
||||
date.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: lifecycle-destroy-node
|
||||
risk_class: destructive
|
||||
inputs: [node_name]
|
||||
verification: "homelab node <name> relations returns unknown-entity; pct list on the backend no longer shows it"
|
||||
verification: "MCP get_blast_radius returns unknown-entity; pct list on the backend no longer shows it"
|
||||
docs_update_checklist: [archaeology_entry, containers_index_update]
|
||||
transition: "deprecated -> destroyed"
|
||||
---
|
||||
@@ -15,28 +15,35 @@ recipients removed + re-keyed, ingress/DNS removed, archaeology entry,
|
||||
ledger entry.
|
||||
|
||||
1. Confirm the node is `deprecated` with zero `affected_by` edges
|
||||
(`homelab node <name> relations`) — do not skip this even if the
|
||||
deprecation runbook was followed recently; state can drift.
|
||||
2. If it's an enrolled client: `homelab client remove <name>` — revokes
|
||||
the age key, re-keys SOPS, removes the inventory entry. This is
|
||||
already destructive-class and confirmed in the CLI.
|
||||
(MCP `get_blast_radius`) — do not skip this even if the deprecation
|
||||
runbook was followed recently; state can drift.
|
||||
2. **If it's an enrolled client: no current tool for revoking its age key /
|
||||
removing its Infisical identity.** The old `homelab client remove`
|
||||
(age key revocation + SOPS re-key + inventory removal, all one
|
||||
destructive-class CLI call) is retired along with the rest of that CLI
|
||||
and hasn't been re-verified against the current enrollment
|
||||
architecture (`POST /api/v1/clients/enroll` + Infisical machine
|
||||
identities) — see the "Open questions" section in
|
||||
[agent-enrollment.md](../../operations/agent-enrollment.md). Until
|
||||
that's confirmed, treat key/identity revocation as a manual step:
|
||||
at minimum remove the client's `age_pubkey` from any SOPS recipient
|
||||
lists and rotate credentials whose ciphertext it already decrypted.
|
||||
3. Remove any ingress route (Caddy config repo) and DNS record still
|
||||
pointing at it.
|
||||
4. Verify backups of anything on it are retained per policy before the
|
||||
disk goes away (see `backs-up-to`).
|
||||
5. Destroy the LXC/VM (`pct destroy` / `qm destroy`).
|
||||
6. Move the `hosts.<name>:` block (if any inventory remnant survives
|
||||
`client remove`, e.g. infra-only LXCs with no age key) into
|
||||
inventory.yaml's `archaeology:` section: `pve_id`, `destroyed` date,
|
||||
`reason`. Add a row to `containers/index.md` "Recently destroyed"
|
||||
table (kept for human-readable browsing alongside the structured
|
||||
data).
|
||||
7. `oikos/ledger.py append host:<name> destroy destructive --result ok`.
|
||||
8. Regenerate: `python3 mcp/build_host_files.py && python3
|
||||
inventory.yaml` — the node drops out of `inventory.yaml` and
|
||||
appears in the topology doc's archaeology table.
|
||||
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
|
||||
one) — `pve_id`, `destroyed` date, `reason` — then `oikos seed` to
|
||||
ingest. Add a row to `containers/index.md` "Recently destroyed" table
|
||||
(kept for human-readable browsing alongside the structured data).
|
||||
7. No manual ledger step — mutations through the API are recorded
|
||||
automatically in the `audit_log` table (MCP `get_audit_trail`,
|
||||
`get_change_history`). The old `oikos/ledger.py append` was retired
|
||||
when this became automatic.
|
||||
|
||||
If the destroy fails partway (e.g. secrets revoked but pct destroy
|
||||
errors), do not re-run step 2 — `client remove` is not idempotent
|
||||
against a second revocation attempt on the issuance server. Finish the
|
||||
remaining steps manually and note the partial state in an investigation.
|
||||
If the destroy fails partway (e.g. secrets not fully revoked but pct
|
||||
destroy errors), finish the remaining steps manually and note the
|
||||
partial state in an investigation (MCP `upsert_knowledge`,
|
||||
`kind: investigation`).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: lifecycle-migrate-node
|
||||
risk_class: config_mutation
|
||||
inputs: [node_name, source_host, target_host]
|
||||
verification: "homelab node <name> relations (re-check blast radius); homelab service <svc> health for every hosted service"
|
||||
verification: "MCP get_blast_radius (re-check blast radius); MCP get_service_status for every hosted service"
|
||||
docs_update_checklist: [doc_page_migration_note, inventory_host_and_lan_ip]
|
||||
transition: "active -> migrating -> active"
|
||||
---
|
||||
@@ -10,12 +10,12 @@ transition: "active -> migrating -> active"
|
||||
# Lifecycle: migrate a node
|
||||
|
||||
Modeled on the strong Phase 1+2 migration
|
||||
([plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md](../../../.hermes/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md)).
|
||||
([archive/hermes-plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md](../../../archive/hermes-plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md)).
|
||||
Requires (ontology): preflight + backup-verified before migrating;
|
||||
post-verify + Caddy backends checked + mounts checked + docs updated
|
||||
before returning to `active`.
|
||||
|
||||
1. `homelab change preflight <every service the node hosts>` — capture
|
||||
1. MCP `preflight` for every service the node hosts — capture
|
||||
current health as a baseline.
|
||||
2. Verify backups are current for anything with data at rest on the
|
||||
node (see `backs-up-to` edges once populated).
|
||||
@@ -29,11 +29,12 @@ before returning to `active`.
|
||||
6. Post-verify: re-run the Week-1 drift check by hand — confirm Caddy's
|
||||
backend IP for each affected service matches the new `lan_ip`
|
||||
(automatic in Week 3's drift detector), confirm mounts still resolve.
|
||||
7. `homelab service <name> health` for every service the node hosts.
|
||||
7. MCP `get_service_status` for every service the node hosts.
|
||||
8. Set `state: active`. Add a migration note to the node's doc page
|
||||
(old host/IP → new, date, phase reference) — this repo's convention
|
||||
for every past migration (see `archive/knowledge/containers/101-jellyfin.md`,
|
||||
`containers/129-house.md`).
|
||||
|
||||
Regenerate: `python3 mcp/build_host_files.py && python3
|
||||
inventory.yaml`.
|
||||
Regenerate: `oikos seed` (re-ingests `seeds/inventory.yaml` into the DB —
|
||||
the DB is authoritative at runtime, the YAML is the source of truth
|
||||
on disk).
|
||||
|
||||
@@ -21,10 +21,12 @@ chosen, doc page stub.
|
||||
`qm create`), choosing the storage pool deliberately — record it as
|
||||
the `storage:` field once populated (Week 1 schema; not yet backfilled
|
||||
for existing nodes).
|
||||
2. Add the inventory entry: `homelab client add <name>` for anything that
|
||||
will run the `homelab` CLI, or a direct `hosts.<name>:` block with
|
||||
`state: provisioning`, `kind`, `host`, `pve_id`, `lan_ip` for
|
||||
infra-only LXCs that won't self-enroll.
|
||||
2. Add the inventory entry: a `hosts.<name>:` block in
|
||||
`seeds/inventory.yaml` with `state: provisioning`, `kind`, `host`,
|
||||
`pve_id`, `lan_ip`, then `oikos seed` to ingest it. For anything that
|
||||
will self-enroll as a client afterward (see
|
||||
[CLIENTS.md](../../../CLIENTS.md#enrollment)), the entity must exist in
|
||||
`planned`/`provisioning` state before `bootstrap.sh` runs there.
|
||||
3. Stub the doc page (`containers/<pve_id>-<name>.md` or
|
||||
`vms/<pve_id>-<name>.md`) — even a one-line "provisioning, see plan X"
|
||||
is enough to satisfy the transition requirement.
|
||||
|
||||
@@ -18,7 +18,7 @@ summarised into targets and fixed costs.
|
||||
- `yuvomi-mcp` is running on LXC 129 and connected as an MCP server in Claude.
|
||||
- The CSV is an N26 export (columns: Booking Date, Value Date, Partner Name,
|
||||
Partner Iban, Type, Payment Reference, Account Name, Amount (EUR), …).
|
||||
- API token: `homelab secret yuvomi-api-token` (decrypts on any enrolled client).
|
||||
- API token: `yuvomi-api-token`, via Infisical (primary) or `oikos secret` (SOPS fallback).
|
||||
- Direct API base: `https://house.hubris.network/api/v1`
|
||||
|
||||
---
|
||||
|
||||
@@ -13,7 +13,8 @@ has packages that are **unpacked but not configured**. Symptoms:
|
||||
manually run 'dpkg --configure -a' to correct the problem.`
|
||||
- `dpkg --audit` lists packages with header
|
||||
`The following packages have been unpacked but not yet configured.`
|
||||
- `homelab apt-audit` shows `DPKG: DIRTY(N)` for the host.
|
||||
- `dpkg --audit` on the host directly shows unpacked-not-configured packages
|
||||
(there's no fleet-wide audit tool anymore — check per-host).
|
||||
|
||||
The system is still running the **old** binaries (still in memory), but the
|
||||
**new** binaries are unpacked and waiting for their postinst to run. Two
|
||||
@@ -33,19 +34,20 @@ config dirs, capabilities, etc.). The system might not come back up cleanly.
|
||||
## Path A — target is still reachable over ssh (preferred)
|
||||
|
||||
```
|
||||
homelab ssh <host> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
|
||||
ssh <host> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
|
||||
```
|
||||
|
||||
Or for an LXC by name:
|
||||
Or for an LXC by name (via the MCP `run` tool, or directly on the Proxmox
|
||||
host):
|
||||
|
||||
```
|
||||
homelab pct <lxc> exec -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
|
||||
pct exec <lxc> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
|
||||
```
|
||||
|
||||
When that returns, confirm:
|
||||
|
||||
```
|
||||
homelab apt-audit --target <host>
|
||||
ssh <host> -- dpkg --audit
|
||||
```
|
||||
|
||||
Expect `DPKG: ok` and the remaining `UPGR` count to match what's intentionally
|
||||
@@ -89,10 +91,11 @@ DEBIAN_FRONTEND=noninteractive dpkg --configure -a \
|
||||
|
||||
## Prevention
|
||||
|
||||
The `homelab apt-upgrade` wrapper launches apt inside a `systemd-run --collect`
|
||||
unit on the target, so it survives ssh teardown — the failure mode that put
|
||||
hubris into this state in the first place is no longer reachable through the
|
||||
standard tool. If you absolutely need to run apt manually over ssh, wrap it:
|
||||
The old `homelab apt-upgrade` wrapper (retired along with the rest of the
|
||||
`homelab` CLI) used to launch apt inside a `systemd-run --collect` unit on
|
||||
the target so it survived ssh teardown — that's the failure mode that put
|
||||
hubris into this state in the first place. There's no fleet-wide wrapper
|
||||
anymore; if you run apt manually over ssh, wrap it yourself the same way:
|
||||
|
||||
```
|
||||
ssh <host> systemd-run --unit=apt-recovery --collect bash -c 'apt -y upgrade'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: service-health-check
|
||||
risk_class: read_only
|
||||
inputs: [service_name]
|
||||
verification: "homelab service <name> health"
|
||||
verification: "MCP get_service_status"
|
||||
docs_update_checklist: []
|
||||
---
|
||||
|
||||
@@ -10,15 +10,15 @@ docs_update_checklist: []
|
||||
|
||||
Goal: determine whether a service is actually healthy, without ad-hoc SSH.
|
||||
|
||||
1. `homelab service <name> explain` — read the context card: backend,
|
||||
blast radius, doc pointer, risk notes.
|
||||
2. `homelab service <name> health` — live health probe (HTTP code against
|
||||
the service's `url`/`endpoint`). Once the Week-3 scheduler ships, this
|
||||
reads a cached snapshot by default; pass `--live` to force a fresh probe.
|
||||
3. If unhealthy, `homelab service <name> log` (or MCP `tail_log`) for the
|
||||
last 200 lines.
|
||||
4. Cross-check blast radius: `homelab node <name> relations` — is this
|
||||
entity's own backend host healthy? A downstream failure (e.g. `strong`
|
||||
1. MCP `explain` — read the context card: backend, blast radius, doc
|
||||
pointer, risk notes.
|
||||
2. MCP `get_service_status` — live health probe (HTTP code against the
|
||||
service's `url`/`endpoint`); the scheduler also probes on its own
|
||||
interval, so this may reflect a recent cached result, not necessarily
|
||||
a fresh one.
|
||||
3. If unhealthy, `tail_log` for the last 200 lines.
|
||||
4. Cross-check blast radius: MCP `get_blast_radius` — is this entity's
|
||||
own backend host healthy? A downstream failure (e.g. a Proxmox host
|
||||
down) will show up here before the service's own logs explain anything.
|
||||
5. If the fix is a restart: classify first (`seeds/policy.yaml` —
|
||||
`service-restart` is `reversible_low` unless the service has a
|
||||
|
||||
98
.gitea/workflows/desktop.yml
Normal file
98
.gitea/workflows/desktop.yml
Normal file
@@ -0,0 +1,98 @@
|
||||
name: Desktop App
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'desktop-*'
|
||||
- 'v[0-9]+.[0-9]+.[0-9]*'
|
||||
|
||||
jobs:
|
||||
build-ui:
|
||||
name: Build SPA
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
working-directory: web
|
||||
- run: npm run build
|
||||
working-directory: web
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: spa-dist
|
||||
path: web/dist/
|
||||
|
||||
build-macos-arm64:
|
||||
name: macOS (arm64)
|
||||
needs: build-ui
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: spa-dist
|
||||
path: cmd/desktop/frontend/dist/
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
- run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
|
||||
- run: wails3 build -clean
|
||||
working-directory: cmd/desktop
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
- run: |
|
||||
cd cmd/desktop/build/bin
|
||||
zip -r oikos-desktop-darwin-arm64.zip oikos-desktop.app
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oikos-desktop-darwin-arm64
|
||||
path: cmd/desktop/build/bin/oikos-desktop-darwin-arm64.zip
|
||||
|
||||
build-linux-amd64:
|
||||
name: Linux (amd64)
|
||||
needs: build-ui
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: spa-dist
|
||||
path: 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: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
|
||||
- run: wails3 build -clean
|
||||
working-directory: cmd/desktop
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oikos-desktop-linux-amd64
|
||||
path: cmd/desktop/build/bin/oikos-desktop
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: [build-macos-arm64, build-linux-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: oikos-desktop-darwin-arm64
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: oikos-desktop-linux-amd64
|
||||
- name: Release
|
||||
uses: https://gitea.com/actions/release-action@v1
|
||||
with:
|
||||
files: |
|
||||
oikos-desktop-darwin-arm64.zip
|
||||
oikos-desktop-linux-amd64
|
||||
api_key: ${{ secrets.GITEA_TOKEN }}
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -2,25 +2,23 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Regenerated every scheduler run; ephemeral health-probe cache.
|
||||
oikos/state.json
|
||||
|
||||
# Compiled binaries (Go rewrite — bin/oikos, bin/nomos)
|
||||
bin/oikos
|
||||
bin/nomos
|
||||
oikos/oikos
|
||||
oikos
|
||||
webhook
|
||||
|
||||
# Legacy Python oikos (superseded by cmd/oikos Go binary — Phase 1-6 rewrite).
|
||||
# oikos/ kernel files are still imported by bin/homelab for operational CLI
|
||||
# commands (ssh, pct, logs, restart, status, open, secret, client, sync, mcp).
|
||||
# Remove oikos/* when bin/homelab is ported to Go.
|
||||
backups/
|
||||
.env
|
||||
.infisical-credentials
|
||||
|
||||
# Web UI (Svelte 5) — build artifacts. Ignore built output but keep the
|
||||
# .gitkeep placeholder so `//go:embed all:dist` (web/embed.go) compiles on a
|
||||
# fresh checkout before the UI is built.
|
||||
web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
# Web UI (Svelte 5) — build artifacts. The SPA is a standalone static build,
|
||||
# deployed separately from the oikos binary (plans/2026-07-12-wails-desktop-app.md
|
||||
# 0.1), so the output dir is just a build artifact.
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
|
||||
# Wails desktop app — frontend copy for embedding
|
||||
cmd/desktop/frontend/dist/
|
||||
cmd/desktop/build/
|
||||
cmd/desktop/oikos-desktop
|
||||
|
||||
104
AGENTS.md
104
AGENTS.md
@@ -34,7 +34,8 @@ Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read:
|
||||
|
||||
That file tells you your role, your peers, what's mounted, and what services
|
||||
you host. If it does not exist, this client was not enrolled — stop and tell
|
||||
the operator to run `homelab client add <hostname>` from an existing client.
|
||||
the operator; see [CLIENTS.md](CLIENTS.md#enrollment) for the enrollment flow
|
||||
(the entity needs to exist in `planned`/`provisioning` state first).
|
||||
|
||||
## 2. The topology
|
||||
|
||||
@@ -49,17 +50,24 @@ the operator to run `homelab client add <hostname>` from an existing client.
|
||||
## 3. The MCP server
|
||||
|
||||
The homelab exposes a Model Context Protocol server with structured tools.
|
||||
Endpoint: `https://mcp.hubris.network/mcp`.
|
||||
Endpoint: `https://mcp.hubris.network/mcp`. Every call needs
|
||||
`Authorization: Bearer <token>` — the API has no unauthenticated path except
|
||||
enrollment and `/healthz` (see "Authentication" below for where the token
|
||||
comes from).
|
||||
|
||||
Available tools (21 total):
|
||||
Available tools (33 total):
|
||||
|
||||
Context — observe + orient:
|
||||
get_entity(slug), list_entities(type, limit, cursor),
|
||||
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:
|
||||
get_service_status(service_slug) — systemctl is-active on target host
|
||||
@@ -67,6 +75,8 @@ Available tools (21 total):
|
||||
list_lxcs() — all LXC containers with ID, host, IP, health
|
||||
get_lxc_state(lxc_slug) — pct status from Proxmox host
|
||||
ping_service(service_slug) — HTTP reachability from entity_status
|
||||
list_my_secrets(caller_pubkey) — secrets accessible to this client by
|
||||
age public key
|
||||
|
||||
Oikos — decisions:
|
||||
explain(service_slug) — compact context card (type, state, health, relations)
|
||||
@@ -84,11 +94,27 @@ Available tools (21 total):
|
||||
get_trend(entity_id, days=7) — metric slope over time
|
||||
get_event_timeline(severity, entity_slug, limit) — recent events
|
||||
|
||||
Execution — the single mutation path:
|
||||
request_execution(target, action, params) — policy-gated.
|
||||
reversible_low (restart, reload, pct_exec, apt audit) runs immediately;
|
||||
config_mutation (systemctl enable/disable, apt upgrade) queues for operator
|
||||
approval via Matrix, then executes on ✅.
|
||||
Knowledge — keep the graph current (none require approval; this updates
|
||||
the knowledge graph, not live infrastructure):
|
||||
upsert_knowledge(title, content) — record what you learned after solving
|
||||
a non-obvious problem; the only way anything persists past a session
|
||||
update_entity_attributes(slug, attributes) — merge a discovered fact
|
||||
(IP, version, port, ...) into an entity so a future task doesn't
|
||||
rediscover it from scratch
|
||||
create_relationship(source, target, type) — record a discovered edge
|
||||
(depends-on, hosts, routes-to, ...) between two entities
|
||||
|
||||
Execution — mutating the live infrastructure:
|
||||
run(target, command) — the general execution primitive. Run any shell
|
||||
command against a host or LXC; every command is auto-classified —
|
||||
read-only inspection runs immediately, anything state-changing needs
|
||||
operator approval, and destructive patterns (rm -rf, dd, mkfs,
|
||||
pct/qm destroy, DROP TABLE, reboot, curl-pipe-to-shell, ...) always
|
||||
need approval regardless of what you declare. Prefer this over
|
||||
request_execution for anything not already covered by its fixed enum.
|
||||
request_execution(target, action, params) — the older, fixed-enum path
|
||||
(restart, systemctl, pct_exec, apt_upgrade, pct_create). Still the
|
||||
route for those specific actions; policy-gated the same way `run` is.
|
||||
get_execution_status(execution_id) — poll progress
|
||||
|
||||
**When to prefer MCP over grepping the clone:** always for knowledge queries.
|
||||
@@ -97,7 +123,18 @@ the DB with entity links. `get_entity_knowledge("lxc:jellyfin")` returns documen
|
||||
runbooks, and investigations in one call. Grep the clone only when MCP is
|
||||
unreachable.
|
||||
|
||||
## 4. Knowledge conventions
|
||||
## 4. Authentication
|
||||
|
||||
Every API/MCP route requires `Authorization: Bearer <token>` except
|
||||
`POST /api/v1/clients/enroll` and `/healthz`. Enrollment (see
|
||||
[CLIENTS.md](CLIENTS.md#enrollment)) does not currently issue a per-client
|
||||
API/MCP bearer token — there is one shared
|
||||
secret (`OIKOS_MCP_BEARER_TOKEN`, validated in `internal/httpapi/server.go`'s
|
||||
`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
|
||||
stores a token in `localStorage` (see `web/src/pages/Config.svelte`).
|
||||
|
||||
## 5. Knowledge conventions
|
||||
|
||||
All narrative knowledge (documents, investigations, runbooks) lives in the DB
|
||||
(`knowledge_entities` table) and is seeded from `seeds/knowledge.yaml`. Agents
|
||||
@@ -122,47 +159,52 @@ per the DB-as-source-of-truth plan.
|
||||
running state, update the DB *in the same session* via the API. The `oikos export`
|
||||
command regenerates `seeds/knowledge.yaml` for version control.
|
||||
|
||||
## 5. Acting on the homelab
|
||||
## 6. Acting on the homelab
|
||||
|
||||
- **Read state**: use MCP tools. Nomos (the AI agent) is the primary
|
||||
operator interface — it has 21 MCP tools for observe/orient/decide/act.
|
||||
- **Actions** (restart, logs, apt, pct exec): Nomos calls `request_execution`
|
||||
via MCP. `reversible_low` actions execute immediately; `config_mutation`
|
||||
and `destructive` actions are queued for operator approval via Matrix.
|
||||
- **Secrets**: managed by Infisical (`oikos secret` subcommand for migration).
|
||||
Never hardcode secrets — use env vars from `.env`.
|
||||
operator interface — it has 33 MCP tools for observe/orient/decide/act
|
||||
(§3).
|
||||
- **Actions** (restart, logs, apt, pct exec, or anything else): Nomos calls
|
||||
`run` (the general execution primitive) or `request_execution` (the older
|
||||
fixed-enum path) via MCP. `reversible_low`/read-only actions execute
|
||||
immediately; `config_mutation` and `destructive` actions are queued for
|
||||
operator approval via Matrix or the control-room UI's Operations page.
|
||||
- **Secrets**: managed by Infisical (`oikos secret` subcommand for
|
||||
migration). Never hardcode secrets — use env vars from `.env`.
|
||||
- **Mutations** (restart, edit configs, etc.): classified against
|
||||
`seeds/policy.yaml`. `reversible_low` actions auto-execute;
|
||||
`config_mutation`/`destructive` actions require approval.
|
||||
a valid `--approval-id` from `homelab approval request` — see OIKOS.md.
|
||||
`config_mutation`/`destructive` actions require approval — granted by
|
||||
the operator via Matrix reply or the control-room UI, not a CLI flag.
|
||||
See OIKOS.md.
|
||||
|
||||
## 6. Communication mode
|
||||
## 7. Communication mode
|
||||
|
||||
Read and apply `/opt/homelab-context/.agents/shared/caveman.md` (if present). It defines the lab's
|
||||
terse-communication standard — drop filler, keep substance, use fragments.
|
||||
|
||||
## 7. Auto-setup mechanism
|
||||
## 8. Auto-setup mechanism
|
||||
|
||||
The homelab-context repo ships tooling that gets automatically installed
|
||||
on every client after `git pull`. This is handled by `tools/post-pull.sh`
|
||||
(replaces the raw git pull in the sync timer) which runs any script matching
|
||||
`tools/*.setup.sh` after pull.
|
||||
`tools/setup-*.sh` after pull.
|
||||
|
||||
Currently auto-setup:
|
||||
- **Caveman + templates** (`tools/setup-caveman.sh`): Installs Caveman npm
|
||||
package, wrapper scripts, and compact output templates for token-efficient
|
||||
CLI output. Wrapper at `~/bin/caveman_wrapper.sh`.
|
||||
- **Nomos agent persona** (`tools/setup-nomos-soul.sh`): Provisions
|
||||
`~/.nomos/SOUL.md` from `NOMOS.md` on Nomos agents. This ensures every
|
||||
Nomos agent follows the canonical homelab persona (token efficiency, source
|
||||
of truth hierarchy). No-op on non-Nomos agents.
|
||||
- **Host checks** (`tools/setup-checks.sh`): Deploys `checks/install.sh`'s
|
||||
health-check scripts to `/opt/oikos/checks` on each host. The scheduler's
|
||||
`ssh-script` check kind depends on these actually being there — 20 are
|
||||
live in the DB as of 2026-07-12.
|
||||
|
||||
To add a new auto-setup, create `tools/<name>.setup.sh` in the repo,
|
||||
To add a new auto-setup, create `tools/setup-<name>.sh` in the repo,
|
||||
commit and push. All enrolled clients pick it up within 5 minutes.
|
||||
|
||||
To trigger sync manually: `sudo homelab sync` or wait for the 5-min timer.
|
||||
To trigger sync manually: run `/opt/homelab/tools/context-poller.sh`, or
|
||||
wait for the 5-min timer. (This mechanism — and the server-side
|
||||
`tools_changed` detection behind it — only correctly recognized
|
||||
`setup-*.sh` scripts as of 2026-07-12; before that it silently matched
|
||||
nothing, so nothing auto-ran on any client via this path.)
|
||||
|
||||
## 8. When in doubt
|
||||
## 9. When in doubt
|
||||
|
||||
Use MCP tools: `search_knowledge <query>` for narrative context,
|
||||
`get_entity <slug>` for structured data, `get_entity_knowledge <slug>` for
|
||||
|
||||
20
CLIENTS.md
20
CLIENTS.md
@@ -24,9 +24,22 @@ Docker stack on mac-mini and exposes an MCP server + REST API.
|
||||
| State snapshot (health, disk, drift) | MCP `get_state_snapshot` |
|
||||
| Secrets (Infisical) | REST API + `oikos secret` CLI |
|
||||
| Approval tokens | Matrix via notifier |
|
||||
| Run a command on a host/LXC (policy-gated) | MCP `run` |
|
||||
| Record a discovered fact/relationship | MCP `update_entity_attributes`, `create_relationship`, `upsert_knowledge` |
|
||||
|
||||
All MCP tools are read-only. Mutations use the `homelab` CLI with operator
|
||||
approval.
|
||||
Most MCP tools are read-only; a few mutate the knowledge graph (recording
|
||||
what you learned) or the live infrastructure (`run`, `request_execution`),
|
||||
gated by risk classification and — for `config_mutation`/`destructive`
|
||||
actions — operator approval. See [AGENTS.md](AGENTS.md#3-the-mcp-server) for
|
||||
the full tool catalog.
|
||||
|
||||
## Authentication
|
||||
|
||||
Every API/MCP call needs `Authorization: Bearer <token>` — there is no
|
||||
unauthenticated path except `POST /api/v1/clients/enroll` and `/healthz`.
|
||||
Enrollment (below) does not currently hand out a per-client bearer token;
|
||||
get the shared `OIKOS_MCP_BEARER_TOKEN` from the operator until per-client
|
||||
token issuance exists.
|
||||
|
||||
## Enrollment
|
||||
|
||||
@@ -41,7 +54,6 @@ curl -fsSL https://git.hubris.network/dtoro/oikos/raw/main/bootstrap.sh | sudo b
|
||||
|
||||
# Or with optional tooling:
|
||||
curl ... | sudo bash -s -- --with-mcp # wire Claude's MCP config
|
||||
curl ... | sudo bash -s -- --with-nomos # install Goose + Nomos
|
||||
```
|
||||
|
||||
This calls `POST /api/v1/clients/enroll` on the Oikos API, which:
|
||||
@@ -56,7 +68,7 @@ This calls `POST /api/v1/clients/enroll` on the Oikos API, which:
|
||||
### What changes on your machine
|
||||
|
||||
- `/opt/homelab/` — agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md)
|
||||
- `/opt/homelab/tools/` — tooling scripts (caveman, nomos-soul)
|
||||
- `/opt/homelab/tools/` — tooling scripts (checks)
|
||||
- `/etc/age/key.txt` — age private key for SOPS decryption (fallback)
|
||||
- `/etc/infisical/identity` — Infisical machine identity (primary secrets)
|
||||
- Context poller — launchd/systemd timer hits `GET /api/v1/clients/{slug}/context` every 5 minutes for agent file updates
|
||||
|
||||
@@ -9,10 +9,13 @@ repo, see [.agents/dev/CONTRIBUTING.md](.agents/dev/CONTRIBUTING.md).
|
||||
- **Go 1.26+** (see `go.mod` for pinned version)
|
||||
- **PostgreSQL with TimescaleDB** — the compose stack includes `timescale/timescaledb:2.17.2-pg16`
|
||||
- **Docker** for the full dev stack
|
||||
- **Node 22+** for `web/` (the control-room SPA — standalone, not part of the
|
||||
compose stack or the `oikos` binary)
|
||||
|
||||
```bash
|
||||
# Start dependencies (Postgres + Redis)
|
||||
docker compose --profile dev up -d
|
||||
# Start dependencies (Postgres + Redis). api/nomos require a shared bearer
|
||||
# token — no dev-open bypass — so set one even for local dev.
|
||||
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d
|
||||
|
||||
# Run all tests
|
||||
make test
|
||||
@@ -22,13 +25,21 @@ make test-db
|
||||
|
||||
# Build the binary
|
||||
make build
|
||||
|
||||
# SPA dev server (proxies to api/nomos, injecting the same token)
|
||||
cd web && OIKOS_API_TOKEN=dev-token npm run dev
|
||||
```
|
||||
|
||||
## 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
|
||||
cmd/oikos/ Single-binary entry point
|
||||
cmd/nomos/ Nomos MCP client gateway
|
||||
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
|
||||
internal/ All Go packages
|
||||
httpapi/ REST + MCP server (OpenAPI-generated)
|
||||
mcp/ MCP tool implementations
|
||||
@@ -42,15 +53,20 @@ internal/ All Go packages
|
||||
domain/ Core types: entities, approvals, signals, patterns
|
||||
ontology/ Type hierarchy, relationship validation
|
||||
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
|
||||
migrations/ Forward-only SQL migrations (TimescaleDB)
|
||||
seeds/ Bootstrap YAML: ontology, inventory, policy, knowledge
|
||||
compose/ Dockerfiles + Caddy config
|
||||
scripts/ Deploy, watchdog, rollback
|
||||
checks/ Host health-check scripts run over SSH by the scheduler
|
||||
tools/ Client auto-setup scripts (checks)
|
||||
nomos/ Nomos config, persona, skills
|
||||
.agents/ Agent instruction files + skills
|
||||
plans/ Design documents
|
||||
docs/adr/ Architecture decision records
|
||||
docs/operations/ Runbooks (rollback, etc.)
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -68,6 +84,12 @@ docs/adr/ Architecture decision records
|
||||
| `make export` | Export DB state to YAML seeds |
|
||||
| `make dev` | Start compose dev stack |
|
||||
| `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 webhook` | Build `cmd/webhook` (deploy-webhook receiver) |
|
||||
| `make tidy` | `go mod tidy` |
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
33
Makefile
33
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: build test test-db lint generate generate-check dev migrate seed export clean tidy
|
||||
.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package desktop-release
|
||||
|
||||
BINARY := oikos
|
||||
GO ?= go
|
||||
@@ -6,6 +6,9 @@ GO ?= go
|
||||
build:
|
||||
$(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos
|
||||
|
||||
webhook:
|
||||
$(GO) build -o webhook -tags timetzdata ./cmd/webhook
|
||||
|
||||
test:
|
||||
$(GO) test -race -cover ./...
|
||||
|
||||
@@ -42,8 +45,36 @@ export:
|
||||
dev:
|
||||
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 && wails3 build -clean
|
||||
|
||||
desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux)
|
||||
@case $$(uname -s) in \
|
||||
Darwin) \
|
||||
cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip oikos-desktop.app ;; \
|
||||
Linux) \
|
||||
cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz oikos-desktop ;; \
|
||||
esac
|
||||
@echo "Package: cmd/desktop/build/bin/"
|
||||
|
||||
desktop-release: ui ## Build desktop app for macOS arm64 + Linux amd64 (CI target)
|
||||
@echo "Use 'make desktop-package' for local builds; desktop-release is for CI"
|
||||
@exit 1
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
rm -rf cmd/desktop/build
|
||||
rm -rf cmd/desktop/frontend/dist
|
||||
$(GO) clean -testcache
|
||||
|
||||
tidy:
|
||||
|
||||
39
README.md
39
README.md
@@ -13,18 +13,24 @@ learns from outcomes, and escalates when uncertain.
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Dev stack (postgres + api + scheduler + notifier)
|
||||
docker compose --profile dev up -d
|
||||
# Dev stack (postgres + api + scheduler + notifier). The api/nomos
|
||||
# services need a shared token — every route requires a real bearer
|
||||
# credential, there's no dev-open bypass.
|
||||
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d
|
||||
|
||||
# Full stack (adds Nomos agent gateway)
|
||||
docker compose --profile full up -d
|
||||
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile full up -d
|
||||
|
||||
# Build standalone binary
|
||||
go build -o bin/oikos -tags timetzdata ./cmd/oikos
|
||||
|
||||
# Run all roles in one process (dev mode)
|
||||
OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" \
|
||||
OIKOS_API_TOKEN=dev-token \
|
||||
go run ./cmd/oikos all
|
||||
|
||||
# Control-room SPA (separate from the Go binary — see web/)
|
||||
cd web && OIKOS_API_TOKEN=dev-token npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -59,16 +65,19 @@ OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disa
|
||||
| 5 — Secrets | ✅ | Infisical backend + SOPS fallback, rotation runbooks |
|
||||
| 6 — Deploy | ✅ | CI pipeline, cutover checklist, watchdog, rollback |
|
||||
|
||||
Full plan: [plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md).
|
||||
Full plan: [plans/done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](plans/done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md).
|
||||
|
||||
## Operations
|
||||
|
||||
### API endpoints
|
||||
|
||||
```bash
|
||||
curl http://localhost:8090/api/v1/entities?type=service # fleet
|
||||
curl http://localhost:8090/api/v1/health # fleet health
|
||||
curl http://localhost:8090/api/v1/agent-activity # agent log
|
||||
curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \
|
||||
http://localhost:8090/api/v1/entities?type=service # fleet
|
||||
curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \
|
||||
http://localhost:8090/api/v1/health # fleet health
|
||||
curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \
|
||||
http://localhost:8090/api/v1/agent-activity # agent log
|
||||
```
|
||||
|
||||
### Nomos queries
|
||||
@@ -97,24 +106,40 @@ oikos secret list # enumerate SOPS secrets
|
||||
oikos secret migrate # SOPS → Infisical
|
||||
```
|
||||
|
||||
### Web UI
|
||||
|
||||
`web/` is a standalone Svelte 5 SPA — not embedded in the `oikos` binary, not
|
||||
part of `docker-compose.yml`. It talks to `api`/`nomos` over HTTP with a
|
||||
bearer token entered on first launch (see `web/src/pages/Config.svelte`).
|
||||
Build with `make ui`, deploy with `make deploy-ui` (Caddy serves the static
|
||||
output). A native desktop wrapper is planned — see
|
||||
[plans/2026-07-12-wails-desktop-app.md](plans/2026-07-12-wails-desktop-app.md).
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
cmd/oikos/ Go entry point — single binary
|
||||
cmd/nomos/ Nomos MCP client gateway
|
||||
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
|
||||
internal/ Go packages (httpapi, mcp, scheduler, actuator, learning,
|
||||
notifier, policy, secrets, db, config, ontology, domain,
|
||||
knowledge)
|
||||
web/ Control-room SPA (Svelte 5) — standalone, not embedded
|
||||
api/openapi.yaml API contract (OpenAPI 3.1)
|
||||
migrations/ Forward-only SQL migrations (TimescaleDB)
|
||||
seeds/ Bootstrap YAML (ontology, inventory, policy, knowledge)
|
||||
compose/ Dockerfiles + Caddy config
|
||||
scripts/ Deploy, watchdog, verification, rollback
|
||||
checks/ Host health-check scripts run over SSH by the scheduler
|
||||
tools/ Client auto-setup scripts (checks)
|
||||
ssh/ Deploy keys + authorized_keys management
|
||||
vps/ Caddy/TURN config templates for the netbird VPS
|
||||
nomos/ Nomos config, persona, skills
|
||||
.agents/ Agent instruction files, shared conventions, skills
|
||||
archive/ Historical reference (legacy wiki, plans, SOPS backups)
|
||||
plans/ Design documents (active + done)
|
||||
docs/adr/ Architecture decision records
|
||||
docs/operations/ Runbooks (rollback, etc.)
|
||||
```
|
||||
|
||||
## For agents
|
||||
|
||||
41
bootstrap.sh
41
bootstrap.sh
@@ -3,15 +3,14 @@
|
||||
#
|
||||
# Thin client model (rev 2): no git clone, no sync timer. Fetches only the
|
||||
# agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md) and tooling
|
||||
# (caveman, nomos-soul) from the raw Gitea URL. Enrolls via the Oikos API
|
||||
# to receive an age keypair and Infisical machine identity. A lightweight
|
||||
# context poller replaces the old 5-minute git pull.
|
||||
# (checks) from the raw Gitea URL. Enrolls via the Oikos API to receive an
|
||||
# age keypair and Infisical machine identity. A lightweight context poller
|
||||
# replaces the old 5-minute git pull.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://git.hubris.network/dtoro/oikos/raw/main/bootstrap.sh \
|
||||
# | sudo bash
|
||||
# curl ... | sudo bash -s -- --with-mcp # wire Claude's .mcp.json
|
||||
# curl ... | sudo bash -s -- --with-nomos # install Goose + Nomos
|
||||
# curl ... | sudo bash -s -- --dry-run # show what would happen
|
||||
#
|
||||
# Prerequisites:
|
||||
@@ -28,16 +27,10 @@ REPO_RAW_URL="${HOMELAB_RAW_URL:-https://git.hubris.network/dtoro/oikos/raw/main
|
||||
OIKOS_API_URL="${HOMELAB_OIKOS_URL:-https://oikos.hubris.network/api/v1}"
|
||||
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
|
||||
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/mcp}"
|
||||
NOMOS_MCP_URI="${HOMELAB_NOMOS_MCP_URI:-https://mcp.hubris.network/mcp}"
|
||||
NOMOS_MODEL="${HOMELAB_NOMOS_MODEL:-nousresearch/hermes-4-405b}"
|
||||
|
||||
WITH_MCP=0
|
||||
WITH_NOMOS=0
|
||||
DRY_RUN=0
|
||||
|
||||
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
|
||||
GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
log() { echo "[oikos] $*"; }
|
||||
@@ -79,10 +72,7 @@ detect_mesh_ip() {
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--with-mcp) WITH_MCP=1 ;;
|
||||
--with-nomos) WITH_NOMOS=1 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--gitea-token) GITEA_TOKEN="$2"; shift ;;
|
||||
--gitea-user) GITEA_USER="$2"; shift ;;
|
||||
*) die "unknown flag: $1" ;;
|
||||
esac
|
||||
shift
|
||||
@@ -148,7 +138,7 @@ done
|
||||
|
||||
# ── fetch tools ──────────────────────────────────────────────────────
|
||||
log "fetching tools..."
|
||||
for tool in setup-caveman.sh setup-nomos-soul.sh caveman.js caveman_wrapper.sh post-pull.sh; do
|
||||
for tool in setup-checks.sh post-pull.sh; do
|
||||
url="$REPO_RAW_URL/tools/${tool}"
|
||||
dest="$CLONE_DIR/tools/${tool}"
|
||||
dry mkdir -p "$(dirname "$dest")"
|
||||
@@ -161,16 +151,6 @@ for tool in setup-caveman.sh setup-nomos-soul.sh caveman.js caveman_wrapper.sh p
|
||||
fi
|
||||
done
|
||||
|
||||
# ── fetch caveman templates ──────────────────────────────────────────
|
||||
for tmpl in git_log.txt git_status.txt test_results.txt; do
|
||||
url="$REPO_RAW_URL/tools/caveman/templates/${tmpl}"
|
||||
dest="$CLONE_DIR/tools/caveman/templates/${tmpl}"
|
||||
dry mkdir -p "$(dirname "$dest")"
|
||||
if curl -fsSL --connect-timeout 10 "$url" -o "$dest.tmp" 2>/dev/null; then
|
||||
mv "$dest.tmp" "$dest"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── detect control-plane (use localhost if API is reachable directly) ─
|
||||
if [ -z "${HOMELAB_OIKOS_URL:-}" ]; then
|
||||
if curl -s --connect-timeout 2 http://localhost:8090/api/v1/health >/dev/null 2>&1; then
|
||||
@@ -300,7 +280,7 @@ case "$OS" in
|
||||
esac
|
||||
|
||||
# ── run auto-setup scripts ───────────────────────────────────────────
|
||||
for setup in "$CLONE_DIR"/tools/*.setup.sh; do
|
||||
for setup in "$CLONE_DIR"/tools/setup-*.sh; do
|
||||
[ -f "$setup" ] || continue
|
||||
log "running setup: $(basename "$setup")"
|
||||
dry bash "$setup"
|
||||
@@ -319,17 +299,6 @@ if [ "$WITH_MCP" -eq 1 ]; then
|
||||
log " + MCP wired to $MCP_URL"
|
||||
fi
|
||||
|
||||
# ── --with-nomos: install Goose + Nomos wrapper ────────────────────
|
||||
if [ "$WITH_NOMOS" -eq 1 ]; then
|
||||
log "installing Nomos agent..."
|
||||
GOOSE_URL="https://github.com/block/goose/releases/latest/download/goose-${OS}-${ARCH:-amd64}"
|
||||
if [ "$OS" = Darwin ]; then GOOSE_URL="https://github.com/block/goose/releases/latest/download/goose-darwin-${ARCH:-arm64}"; fi
|
||||
dry curl -fsSL "$GOOSE_URL" -o /usr/local/bin/goose 2>/dev/null && chmod +x /usr/local/bin/goose || warn "goose not installed"
|
||||
# Drop Nomos persona
|
||||
cp "$CLONE_DIR/NOMOS.md" "$CLONE_DIR/.agents/NOMOS.md" 2>/dev/null || true
|
||||
log " + Nomos agent installed"
|
||||
fi
|
||||
|
||||
# ── netbird SSH JWT cache ────────────────────────────────────────────
|
||||
if command -v netbird >/dev/null 2>&1; then
|
||||
dry netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400 2>/dev/null || true
|
||||
|
||||
26
cmd/desktop/entitlements.plist
Normal file
26
cmd/desktop/entitlements.plist
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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>
|
||||
382
cmd/desktop/main.go
Normal file
382
cmd/desktop/main.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
//go:embed frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
const (
|
||||
keyringService = "com.hubris.oikos-desktop"
|
||||
keyringUser = "oikos"
|
||||
version = "0.1.0"
|
||||
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
|
||||
pollInterval = 30 * time.Second
|
||||
updateInterval = 6 * time.Hour
|
||||
)
|
||||
|
||||
type OikosConfig struct {
|
||||
ApiUrl string `json:"apiUrl"`
|
||||
Token string `json:"token,omitempty"`
|
||||
IsDesktop bool `json:"isDesktop"`
|
||||
}
|
||||
|
||||
// ---- ConfigService ----
|
||||
|
||||
type ConfigService struct{ app *application.App }
|
||||
|
||||
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) 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)
|
||||
}
|
||||
|
||||
// ---- 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)
|
||||
}
|
||||
|
||||
// ---- Config loading ----
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ---- Asset handler ----
|
||||
|
||||
func newAssetHandler(cfg *OikosConfig) http.Handler {
|
||||
distFS, err := fs.Sub(assets, "frontend/dist")
|
||||
if err != nil {
|
||||
log.Fatalf("embedded assets: %v", err)
|
||||
}
|
||||
|
||||
fallback := http.FileServer(http.FS(distFS))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/" || path == "/index.html" {
|
||||
data, err := fs.ReadFile(distFS, "index.html")
|
||||
if err != nil {
|
||||
fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
html := string(data)
|
||||
if cfg != nil {
|
||||
configJSON, _ := json.Marshal(cfg)
|
||||
placeholder := `<script>window.__OIKOS_CONFIG__ = {};</script>`
|
||||
injected := fmt.Sprintf(`<script>window.__OIKOS_CONFIG__ = %s;</script>`, configJSON)
|
||||
html = strings.ReplaceAll(html, placeholder, injected)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(html))
|
||||
return
|
||||
}
|
||||
fallback.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 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"`
|
||||
}
|
||||
|
||||
func checkUpdates() {
|
||||
for {
|
||||
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
|
||||
if err != nil {
|
||||
time.Sleep(updateInterval)
|
||||
continue
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
var releases []giteaRelease
|
||||
if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 {
|
||||
time.Sleep(updateInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
latest := releases[0]
|
||||
latestVersion := strings.TrimPrefix(latest.TagName, "v")
|
||||
if latestVersion == version {
|
||||
time.Sleep(updateInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
app := application.Get()
|
||||
if app == nil {
|
||||
time.Sleep(updateInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Version %s is available (you have %s). Download from Gitea releases.", latestVersion, version)
|
||||
app.Dialog.Info().
|
||||
SetTitle("Update Available").
|
||||
SetMessage(msg).
|
||||
Show()
|
||||
time.Sleep(updateInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Main ----
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
|
||||
app := application.New(application.Options{
|
||||
Name: "Oikos",
|
||||
Description: "Homelab Control Room",
|
||||
Services: []application.Service{
|
||||
application.NewService(&ConfigService{}),
|
||||
},
|
||||
Assets: application.AssetOptions{
|
||||
Handler: newAssetHandler(cfg),
|
||||
},
|
||||
Mac: application.MacOptions{
|
||||
ApplicationShouldTerminateAfterLastWindowClosed: false,
|
||||
},
|
||||
})
|
||||
|
||||
// --- System tray ---
|
||||
systemTray := app.SystemTray.New()
|
||||
systemTray.SetLabel("Oikos")
|
||||
systemTray.SetTooltip("Oikos — Control Room")
|
||||
|
||||
trayMenu := application.NewMenu()
|
||||
trayMenu.Add("Open Control Room").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 checkUpdates() // force immediate check on demand
|
||||
})
|
||||
trayMenu.AddSeparator()
|
||||
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
|
||||
app.Quit()
|
||||
})
|
||||
systemTray.SetMenu(trayMenu)
|
||||
|
||||
// --- Main window ---
|
||||
ws := loadWindowState()
|
||||
width, height := 1400, 900
|
||||
minWidth, minHeight := 1024, 700
|
||||
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: "Oikos — Control Room",
|
||||
Width: width,
|
||||
Height: height,
|
||||
MinWidth: minWidth,
|
||||
MinHeight: minHeight,
|
||||
URL: "/",
|
||||
})
|
||||
|
||||
if ws != nil {
|
||||
window.SetPosition(ws.X, ws.Y)
|
||||
window.SetSize(ws.Width, ws.Height)
|
||||
} else {
|
||||
window.Center()
|
||||
}
|
||||
window.Show()
|
||||
|
||||
systemTray.AttachWindow(window)
|
||||
systemTray.Run()
|
||||
|
||||
// Register shutdown handler to save window state
|
||||
app.OnShutdown(func() {
|
||||
saveWindowState(window)
|
||||
})
|
||||
|
||||
// Start background goroutines
|
||||
go pollDashboard(cfg)
|
||||
go checkUpdates()
|
||||
|
||||
err := app.Run()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
9
cmd/desktop/wails.json
Normal file
9
cmd/desktop/wails.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "oikos",
|
||||
"outputfilename": "oikos-desktop",
|
||||
"frontend:dir": "frontend",
|
||||
"author": {
|
||||
"name": "Hubris",
|
||||
"email": "d.toro.v@pm.me"
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ type agent struct {
|
||||
agentID uuid.UUID
|
||||
reqOpts []option.RequestOption
|
||||
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)
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
@@ -114,6 +115,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
|
||||
agentID: agentID,
|
||||
reqOpts: reqOpts,
|
||||
apiBase: apiBase,
|
||||
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -164,6 +164,9 @@ func (a *agent) approveExecution(ctx context.Context, execID string) (ok bool, s
|
||||
return false, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if a.apiToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+a.apiToken)
|
||||
}
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
|
||||
@@ -29,6 +29,10 @@ func main() {
|
||||
if mcpURL == "" {
|
||||
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")
|
||||
|
||||
agentSlug := os.Getenv("NOMOS_AGENT_SLUG")
|
||||
if agentSlug == "" {
|
||||
@@ -48,12 +52,12 @@ func main() {
|
||||
// One MCP client PER SESSION, not one shared client for the whole
|
||||
// process — see mcpClientPool's doc comment. A dedicated client is
|
||||
// created lazily on each session's first tool call.
|
||||
clientPool := newMCPClientPool(mcpURL)
|
||||
clientPool := newMCPClientPool(mcpURL, mcpToken)
|
||||
// Prove connectivity at startup the same way the old single-client
|
||||
// constructor did, so a misconfigured/unreachable MCP endpoint still
|
||||
// fails fast on boot instead of only on the first real chat. Doesn't
|
||||
// reuse the pool (nothing to key it by yet) — just a throwaway probe.
|
||||
if probe, err := newMCPClient(mcpURL); err != nil {
|
||||
if probe, err := newMCPClient(mcpURL, mcpToken); err != nil {
|
||||
slog.Error("nomos: mcp connect", "url", mcpURL, "error", err)
|
||||
os.Exit(1)
|
||||
} else {
|
||||
@@ -482,6 +486,7 @@ func truncate(s string, n int) string {
|
||||
|
||||
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
|
||||
@@ -500,9 +505,10 @@ type mcpClient struct {
|
||||
toolsCache []toolDef
|
||||
}
|
||||
|
||||
func newMCPClient(baseURL string) (*mcpClient, error) {
|
||||
func newMCPClient(baseURL, token string) (*mcpClient, error) {
|
||||
c := &mcpClient{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
|
||||
@@ -599,6 +605,9 @@ func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCRespo
|
||||
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 {
|
||||
@@ -721,6 +730,7 @@ func (c *mcpClient) close() {
|
||||
// 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
|
||||
}
|
||||
@@ -730,8 +740,8 @@ type pooledMCPClient struct {
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
func newMCPClientPool(baseURL string) *mcpClientPool {
|
||||
return &mcpClientPool{baseURL: baseURL, clients: make(map[string]*pooledMCPClient)}
|
||||
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
|
||||
@@ -758,7 +768,7 @@ func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) {
|
||||
// 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)
|
||||
c, err := newMCPClient(p.baseURL, p.token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/config"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
@@ -21,50 +18,9 @@ import (
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/dtoro/oikos/internal/scheduler"
|
||||
"github.com/dtoro/oikos/internal/secrets"
|
||||
"github.com/dtoro/oikos/web"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// uiHandler serves the control-room SPA from assets embedded at build time
|
||||
// (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*;
|
||||
// the /ui prefix is stripped to index into the embedded dist/ tree. Files are
|
||||
// written via http.ServeContent (not http.FileServer) to avoid its
|
||||
// index.html -> "./" canonical redirect, which loops for /ui/.
|
||||
func uiHandler() http.Handler {
|
||||
dist, err := web.DistFS()
|
||||
if err != nil {
|
||||
slog.Warn("ui: embedded assets unavailable", "error", err)
|
||||
return http.NotFoundHandler()
|
||||
}
|
||||
serve := func(w http.ResponseWriter, r *http.Request, name string) bool {
|
||||
f, err := dist.Open(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
|
||||
return true
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/")
|
||||
if name == "" {
|
||||
name = "index.html"
|
||||
}
|
||||
if serve(w, r, name) {
|
||||
return
|
||||
}
|
||||
// SPA fallback: serve index.html for unknown client-side routes.
|
||||
if serve(w, r, "index.html") {
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
var schedulerRunner = scheduler.RunnerForMain()
|
||||
var notifierRunner = notifier.RunnerForMain()
|
||||
|
||||
@@ -129,7 +85,7 @@ func main() {
|
||||
go notifierRunner(ctx, pool, cfg)
|
||||
|
||||
slog.Info("all: starting api with scheduler + notifier in background")
|
||||
if err := httpapi.ListenAndServe(ctx, pool, cfg, uiHandler()); err != nil {
|
||||
if err := httpapi.ListenAndServe(ctx, pool, cfg); err != nil {
|
||||
slog.Error("api failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -308,7 +264,7 @@ func runAPI(ctx context.Context, cfg config.Config) error {
|
||||
return fmt.Errorf("migrations: %w", err)
|
||||
}
|
||||
|
||||
err = httpapi.ListenAndServe(ctx, pool, cfg, uiHandler())
|
||||
err = httpapi.ListenAndServe(ctx, pool, cfg)
|
||||
if err == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
|
||||
96
cmd/webhook/main.go
Normal file
96
cmd/webhook/main.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/safego"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("WEBHOOK_LISTEN")
|
||||
if port == "" {
|
||||
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")
|
||||
if repoDir == "" {
|
||||
repoDir = os.Getenv("HOME") + "/Projects/oikos"
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/deploy", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read body failed", 400)
|
||||
return
|
||||
}
|
||||
|
||||
sigHex := r.Header.Get("X-Hub-Signature-256")
|
||||
if sigHex == "" {
|
||||
http.Error(w, "missing signature", 401)
|
||||
return
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
if !hmac.Equal([]byte(sigHex), []byte(expected)) {
|
||||
slog.Warn("webhook: invalid signature")
|
||||
http.Error(w, "invalid signature", 401)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("webhook: deploy triggered")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
w.Write([]byte(`{"status":"deploy started"}`))
|
||||
|
||||
safego.Go("webhook:deploy", func() {
|
||||
cmd := exec.Command(repoDir + "/scripts/deploy.sh")
|
||||
cmd.Dir = repoDir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"REPO_DIR="+repoDir,
|
||||
"PROFILE=full",
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
start := time.Now()
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("webhook: deploy failed", "error", err, "duration", time.Since(start))
|
||||
return
|
||||
}
|
||||
slog.Info("webhook: deploy succeeded", "duration", time.Since(start))
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
slog.Info("webhook: listening", "port", port)
|
||||
if err := http.ListenAndServe(port, mux); err != nil {
|
||||
slog.Error("webhook: serve failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,59 @@
|
||||
# Caddy reverse-proxy snippet for Oikos — Phase 6 cutover
|
||||
# Lives in dtoro/caddy-conf repo; auto-deploys to caddy (LXC 121).
|
||||
# Replaces the old MCP server on apps/105 with the Docker stack on mac-mini.
|
||||
# Caddy reverse-proxy snippet for Oikos — Phase 6 cutover, updated for the
|
||||
# client/server split (plans/2026-07-12-wails-desktop-app.md, Phase 0).
|
||||
# Lives in dtoro/caddy-conf repo; auto-deploys to caddy (LXC 121). THIS COPY
|
||||
# IS A REFERENCE, NOT DEPLOYED FROM HERE — keep it in sync manually.
|
||||
#
|
||||
# The SPA is no longer embedded in the oikos binary; it's built and served
|
||||
# by its own container (compose/web/Dockerfile, docker-compose.yml's `web`
|
||||
# service, mac-mini:8091) rather than as static files read off local disk —
|
||||
# see that service's comment for why. Every API/MCP/agent route now requires
|
||||
# a bearer token in all cases (api's dev-open bypass was removed) —
|
||||
# non-browser clients (Wails, curl, a future mobile client) can't complete
|
||||
# Authentik's browser-session login, so those routes bypass `import
|
||||
# authentik` the same way the enrollment endpoint always has and rely on
|
||||
# api's own combinedAuth instead. See the Wails plan's "Plan review"
|
||||
# section, gap 1.
|
||||
#
|
||||
# mac-mini and the LXC subnet are routed, so these target its direct LAN IP
|
||||
# rather than the mesh (netbird) hostname.
|
||||
|
||||
# Oikos REST API (operator) — enrollment endpoint bypasses Authentik
|
||||
oikos.hubris.network {
|
||||
tls {
|
||||
dns ionos {env.IONOS_AUTH_API_TOKEN}
|
||||
}
|
||||
@enroll path /api/v1/clients/enroll
|
||||
handle @enroll {
|
||||
reverse_proxy <mac-mini-mesh-ip>:8090
|
||||
reverse_proxy 192.168.178.182:8090
|
||||
}
|
||||
# Nomos agent, same-origin for the control-room UI (EventSource/fetch can't
|
||||
# set cross-origin auth headers). Authentik gates it; handle_path strips
|
||||
# the /agent prefix so /agent/chat -> nomos /chat.
|
||||
handle_path /agent/* {
|
||||
import authentik
|
||||
reverse_proxy <mac-mini-mesh-ip>:8092
|
||||
# Bearer-token clients — api's combinedAuth (internal/httpapi/server.go)
|
||||
# is the real gate for all three; Authentik would just reject non-browser
|
||||
# callers before they ever get there. /agent/* now goes through api's own
|
||||
# (auth'd) proxy mount rather than straight to nomos:8092, so it's
|
||||
# covered by the same check as /api/v1/* and /mcp.
|
||||
@api path /api/v1/* /mcp /agent/*
|
||||
handle @api {
|
||||
reverse_proxy 192.168.178.182:8090
|
||||
}
|
||||
# Everything else: the static SPA shell, served by the `web` container.
|
||||
# No sensitive data lives here — real enforcement is the bearer-token
|
||||
# check above — Authentik is just a first line of defense against
|
||||
# anonymous crawlers finding the bundle.
|
||||
handle {
|
||||
import authentik
|
||||
reverse_proxy <mac-mini-mesh-ip>:8090
|
||||
reverse_proxy 192.168.178.182:8091
|
||||
}
|
||||
}
|
||||
|
||||
# Oikos MCP endpoint (agents) — no auth required
|
||||
# Oikos MCP endpoint (agents) — bearer token required (api's combinedAuth),
|
||||
# no separate gate here.
|
||||
mcp.hubris.network {
|
||||
reverse_proxy <mac-mini-mesh-ip>:8090
|
||||
reverse_proxy 192.168.178.182:8090
|
||||
}
|
||||
|
||||
# Nomos gateway (workstation access) — formerly hermes.hubris.network
|
||||
# Nomos's own gateway (workstation access) — still has NO auth of its own
|
||||
# (C1, plans/2026-07-11-nomos-agent-code-review.md, still open). Anyone who
|
||||
# can reach this host can talk to nomos directly, bypassing api entirely.
|
||||
# Not fixed by the client/server split — tracked separately.
|
||||
nomos.hubris.network {
|
||||
reverse_proxy <mac-mini-mesh-ip>:8092
|
||||
reverse_proxy 192.168.178.182:8092
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
# Multi-stage Dockerfile for Oikos (ADR 0001: single binary)
|
||||
# Stage 1: build web UI
|
||||
FROM node:22-alpine AS ui-builder
|
||||
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: build Go binary
|
||||
# Dockerfile for Oikos API server. The SPA is no longer embedded (see
|
||||
# plans/2026-07-12-wails-desktop-app.md 0.1) — it's built and deployed
|
||||
# separately as static files (see `make ui` / `make deploy-ui`).
|
||||
# Stage 1: build Go binary
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
@@ -18,8 +11,6 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
# Bring in the built SPA so //go:embed all:dist (web/embed.go) has real assets.
|
||||
COPY --from=ui-builder /web/dist ./web/dist
|
||||
|
||||
RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos
|
||||
|
||||
@@ -31,6 +22,5 @@ RUN apk add --no-cache ca-certificates openssh-client-default
|
||||
COPY --from=builder /oikos /oikos
|
||||
COPY --from=builder /build/seeds /seeds
|
||||
COPY --from=builder /build/migrations /migrations
|
||||
# web/dist is embedded in the binary (web/embed.go) — no runtime copy needed.
|
||||
|
||||
ENTRYPOINT ["/oikos"]
|
||||
|
||||
5
compose/web/Caddyfile
Normal file
5
compose/web/Caddyfile
Normal file
@@ -0,0 +1,5 @@
|
||||
:80 {
|
||||
root * /srv
|
||||
file_server
|
||||
try_files {path} /index.html
|
||||
}
|
||||
18
compose/web/Dockerfile
Normal file
18
compose/web/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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 web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM caddy:2-alpine
|
||||
|
||||
COPY --from=builder /build/web/dist /srv
|
||||
COPY compose/web/Caddyfile /etc/caddy/Caddyfile
|
||||
@@ -1,10 +1,17 @@
|
||||
# Docker Compose for Oikos development
|
||||
# Usage: docker compose up -d postgres (just the DB)
|
||||
# make dev (full dev stack)
|
||||
#
|
||||
# The SPA isn't embedded in the oikos binary (see
|
||||
# plans/2026-07-12-wails-desktop-app.md 0.1/0.6) but it IS part of this
|
||||
# stack as its own `web` service (compose/web/Dockerfile), so it deploys
|
||||
# through the same push-to-main pipeline as everything else. `npm run dev`
|
||||
# in web/ is still the fast local-iteration path.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: timescale/timescaledb:2.17.2-pg16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: oikos
|
||||
POSTGRES_USER: oikos
|
||||
@@ -51,6 +58,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: compose/oikos/Dockerfile
|
||||
restart: unless-stopped
|
||||
profiles: ["dev", "full"]
|
||||
depends_on:
|
||||
seed:
|
||||
@@ -60,6 +68,12 @@ services:
|
||||
OIKOS_API_LISTEN: ":8090"
|
||||
OIKOS_ENV: dev
|
||||
OIKOS_DEBUG: "true"
|
||||
# No dev-open auth bypass (plans/2026-07-12-wails-desktop-app.md 0.4) —
|
||||
# every request needs this token. nomos uses the same value to call
|
||||
# back into api's /mcp and /api/v1/approvals/*/decision.
|
||||
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
|
||||
OIKOS_OIDC_ISSUER: ${OIKOS_OIDC_ISSUER:-https://auth.hubris.network/application/o/oikos/}
|
||||
OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod}
|
||||
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
|
||||
NOMOS_PROXY_URL: http://nomos:8092
|
||||
volumes:
|
||||
@@ -75,6 +89,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: compose/oikos/Dockerfile
|
||||
restart: unless-stopped
|
||||
profiles: ["dev", "full"]
|
||||
depends_on:
|
||||
seed:
|
||||
@@ -98,6 +113,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: compose/oikos/Dockerfile
|
||||
restart: unless-stopped
|
||||
profiles: ["dev", "full"]
|
||||
depends_on:
|
||||
seed:
|
||||
@@ -119,6 +135,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: compose/nomos/Dockerfile
|
||||
restart: unless-stopped
|
||||
profiles: ["full"]
|
||||
depends_on:
|
||||
api:
|
||||
@@ -129,14 +146,32 @@ services:
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro}
|
||||
DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
||||
# Must match api's OIKOS_MCP_BEARER_TOKEN above — api's combinedAuth
|
||||
# rejects every request without it now (no dev-open bypass).
|
||||
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
|
||||
ports:
|
||||
- "8092:8092"
|
||||
stop_signal: SIGTERM
|
||||
stop_grace_period: 10s
|
||||
|
||||
# Control-room SPA — static build served behind Caddy. The outer
|
||||
# production Caddy (caddy-conf repo, LXC 121) splits /api/*, /mcp,
|
||||
# /agent/* off to api:8090 and sends everything else here; this
|
||||
# container only serves static files with SPA-fallback 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:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
profiles: ["infisical", "full"]
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
@@ -149,6 +184,7 @@ services:
|
||||
# Infisical self-hosted (Phase 5 secrets management)
|
||||
infisical:
|
||||
image: infisical/infisical:latest
|
||||
restart: unless-stopped
|
||||
profiles: ["infisical", "full"]
|
||||
depends_on:
|
||||
postgres:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# Signal Trigger Architecture
|
||||
# ADR 0013 — Signal trigger architecture
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-07-08
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Oikos Entity Model — Types, Relationships & Interactions
|
||||
# ADR 0014 — Entity model: types, relationships & interactions
|
||||
|
||||
**Status:** Adopted
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-07-08
|
||||
**Scope:** Full inventory of every entity type, relationship, state machine, and
|
||||
cognition pipeline — with clear markers for what is **code-real** vs **schema-only**.
|
||||
|
||||
62
docs/adr/0015-api-bearer-auth-client-server-split.md
Normal file
62
docs/adr/0015-api-bearer-auth-client-server-split.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# ADR 0015 — Bearer-token auth for every route + client/server split
|
||||
|
||||
Status: accepted (2026-07-12) · Plan: plans/2026-07-12-wails-desktop-app.md, Phase 0
|
||||
|
||||
## Context
|
||||
The control-room SPA was embedded in the `oikos` binary (`go:embed`,
|
||||
ADR 0001) and served at `/ui/*`. `combinedAuth` (`internal/httpapi/server.go`)
|
||||
opened a dev-open bypass — no credential required at all — whenever
|
||||
`OIKOS_ENV=dev` and no static token/OIDC issuer was configured. That was
|
||||
true not just in local dev but in the actual mac-mini production deploy:
|
||||
`docker-compose.yml`'s `api` service hardcoded `OIKOS_ENV: dev` with no
|
||||
token set, so every route (`/api/v1/*`, `/mcp`, and an `/agent` reverse-proxy
|
||||
mount to nomos that had never been wrapped in `combinedAuth` at all) was
|
||||
reachable unauthenticated from anywhere on the mesh/LAN. A planned Wails
|
||||
desktop client and any future non-browser client can't rely on same-origin
|
||||
requests or a dev-open bypass; they need the SPA to be a standalone,
|
||||
CORS-capable client that authenticates over HTTP like any other caller.
|
||||
|
||||
## Decision
|
||||
- Delete the SPA embed (`web/embed.go`, the `/ui/*` routes). `web/` is a
|
||||
standalone static build, deployed separately (`make ui` / `make
|
||||
deploy-ui`), served at `/` by Caddy with SPA fallback.
|
||||
- Remove the dev-open bypass entirely. Every route requires a valid
|
||||
static bearer token (`OIKOS_API_TOKEN` / `OIKOS_MCP_BEARER_TOKEN`) or an
|
||||
OIDC JWT, with two narrow exceptions: `/healthz` (liveness) and
|
||||
`POST /api/v1/clients/enroll` (IP-gated in the handler instead).
|
||||
`GET /api/v1/events/stream` additionally accepts the token as a
|
||||
`?token=` query param, since `EventSource` can't set custom headers.
|
||||
- Add CORS (`github.com/go-chi/cors`, `OIKOS_CORS_ORIGIN`, default `*`) so a
|
||||
cross-origin SPA (Vite dev server, a future Wails webview) can reach the
|
||||
API. No `AllowCredentials` — auth is a header, not a cookie, so
|
||||
credentialed CORS mode isn't needed and the two don't combine safely with
|
||||
a wildcard origin.
|
||||
- Wrap the previously-unauthenticated `/agent` proxy mount in the same
|
||||
`combinedAuth` middleware as every other route.
|
||||
- `cmd/nomos` becomes an authenticated client of `api`: it now sends
|
||||
`Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN` on its own outbound calls
|
||||
(MCP + the chat-assent approval-decision endpoint), which it never did
|
||||
before — dev-open covered for it until now.
|
||||
- The SPA gets a runtime config module (`web/src/lib/config.ts`) and a
|
||||
first-launch `Config.svelte` screen: server URL + token, stored in
|
||||
`localStorage`, injected into every `fetch()` via a shared
|
||||
`fetchWithAuth` wrapper. Resolved fresh per request (not cached at
|
||||
module-load time), so the same build works same-origin or cross-origin
|
||||
without a rebuild.
|
||||
|
||||
## Consequences
|
||||
- Closing dev-open was a live security fix, not just future-proofing —
|
||||
verified post-deploy that unauthenticated requests to production now 401.
|
||||
- Nomos's *own* HTTP gateway (`cmd/nomos`, port 8092) still has no auth of
|
||||
its own — out of scope here, tracked separately
|
||||
(plans/2026-07-11-nomos-agent-code-review.md, finding C1).
|
||||
- Production Caddy (`dtoro/caddy-conf`, not this repo) does not yet expose
|
||||
`oikos.hubris.network` at all, so the interaction between Authentik
|
||||
forward-auth and bearer-token clients (a non-browser client can't
|
||||
complete a browser SSO redirect) is unresolved — needs an `@enroll`-style
|
||||
bypass for `/api/v1/*`/`/mcp`/`/agent/*` before public exposure. This
|
||||
repo's `compose/caddy/Caddyfile.oikos` (a reference copy, not deployed
|
||||
from here) has the bypass; the real config does not yet.
|
||||
- There is one shared bearer secret for all agents/clients, not per-client
|
||||
tokens — acceptable for the current fleet size, revisit if per-client
|
||||
revocation becomes necessary.
|
||||
@@ -5,7 +5,7 @@ after acceptance — superseding decisions get a new ADR that links back.
|
||||
Statuses: proposed | accepted | superseded-by-NNNN.
|
||||
|
||||
| ADR | Title |
|
||||
|---|---|---|
|
||||
|---|---|
|
||||
| [0001](0001-go-single-binary.md) | Go with single-binary role packaging |
|
||||
| [0002](0002-postgres-timescale-only-datastore.md) | PostgreSQL + TimescaleDB as the only datastore |
|
||||
| [0003](0003-db-native-ontology-yaml-seeds.md) | DB-native ontology with YAML seed manifests |
|
||||
@@ -16,7 +16,8 @@ Statuses: proposed | accepted | superseded-by-NNNN.
|
||||
| [0008](0008-forward-only-migrations.md) | Forward-only migrations |
|
||||
| [0009](0009-sse-over-websocket.md) | SSE over WebSocket for the event stream |
|
||||
| [0010](0010-infisical-with-sops-fallback.md) | Infisical secrets with SOPS DR fallback |
|
||||
| [0011](0011-client-lifecycle-flows.md) | Client lifecycle flows — enrollment, bootstrap, sync |
|
||||
| [0012](0012-hermes-oikos-interactions.md) | Hermes–Oikos interactions — agent/OS contract |
|
||||
| [0013](0013-signal-triggers.md) | Signal triggers — host health checks via scheduler |
|
||||
| [0011](0011-client-lifecycle-flows.md) | Client lifecycle sequence diagrams |
|
||||
| [0012](0012-hermes-oikos-interactions.md) | Hermes/Oikos interaction architecture |
|
||||
| [0013](0013-signal-triggers.md) | Signal trigger architecture |
|
||||
| [0014](0014-entity-model.md) | Entity model — types, relationships, state machines, OODA loop |
|
||||
| [0015](0015-api-bearer-auth-client-server-split.md) | Bearer-token auth for every route + client/server split |
|
||||
|
||||
11
go.mod
11
go.mod
@@ -5,6 +5,7 @@ go 1.26.3
|
||||
require (
|
||||
github.com/getkin/kin-openapi v0.140.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/jsonschema-go v0.4.3
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -13,6 +14,8 @@ require (
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/oapi-codegen/runtime v1.4.2
|
||||
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/sync v0.21.0
|
||||
golang.org/x/sys v0.46.0
|
||||
@@ -24,6 +27,7 @@ require (
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // 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/aws/aws-sdk-go-v2 v1.27.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect
|
||||
@@ -39,12 +43,16 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect
|
||||
github.com/aws/smithy-go v1.20.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // 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/swag/jsonname v0.25.5 // 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/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
@@ -53,6 +61,9 @@ require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/yaml3 v0.0.13 // indirect
|
||||
github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect
|
||||
|
||||
35
go.sum
35
go.sum
@@ -7,6 +7,8 @@ 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/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ=
|
||||
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/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8=
|
||||
@@ -40,12 +42,16 @@ 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/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/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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
@@ -57,11 +63,17 @@ github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k
|
||||
github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
|
||||
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/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-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.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
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/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/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
@@ -71,6 +83,8 @@ 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/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0=
|
||||
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/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
@@ -101,11 +115,19 @@ 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/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/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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
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/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/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
|
||||
@@ -142,6 +164,8 @@ 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.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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -159,12 +183,16 @@ 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/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
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/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/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
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/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/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
||||
@@ -214,13 +242,16 @@ 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/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-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-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-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-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.6.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.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
||||
@@ -19,10 +19,16 @@ type Config struct {
|
||||
APIEnv string // dev, prod
|
||||
|
||||
// Auth (Phase 2: static bearer tokens + OIDC JWT)
|
||||
APIToken string // operator/CI bearer token for the REST API
|
||||
MCPBearerToken string // shared secret for Nomos→API MCP calls
|
||||
OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/)
|
||||
OIDCClientID string // OIDC client ID (aud claim expected in JWT)
|
||||
APIToken string // operator/CI bearer token for the REST API
|
||||
MCPBearerToken string // shared secret for Nomos→API MCP calls
|
||||
OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/)
|
||||
OIDCClientID string // OIDC client ID (aud claim expected in JWT)
|
||||
OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients)
|
||||
|
||||
// CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md
|
||||
// 0.3). Needed for the Wails webview and local dev (Vite on a different
|
||||
// port than the API); a no-op when the SPA and API share an origin.
|
||||
CORSAllowedOrigin string
|
||||
|
||||
// Observability
|
||||
Debug bool // verbose logging, probe payloads, SQL
|
||||
@@ -73,6 +79,7 @@ func Default() Config {
|
||||
DatabaseURL: "postgres://oikos:***@localhost:5432/oikos?sslmode=disable",
|
||||
APIListen: ":8090",
|
||||
APIEnv: "dev",
|
||||
CORSAllowedOrigin: "*",
|
||||
SeedsDir: "seeds",
|
||||
MigrationsDir: "migrations",
|
||||
SchedulerInterval: 30 * time.Second,
|
||||
@@ -102,12 +109,18 @@ func FromEnv() Config {
|
||||
if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" {
|
||||
c.OIDCClientID = v
|
||||
}
|
||||
if v := os.Getenv("OIKOS_OIDC_CLIENT_SECRET"); v != "" {
|
||||
c.OIDCClientSecret = v
|
||||
}
|
||||
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
|
||||
c.APIToken = v
|
||||
}
|
||||
if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" {
|
||||
c.MCPBearerToken = v
|
||||
}
|
||||
if v := os.Getenv("OIKOS_CORS_ORIGIN"); v != "" {
|
||||
c.CORSAllowedOrigin = v
|
||||
}
|
||||
if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" {
|
||||
c.SeedsDir = v
|
||||
}
|
||||
|
||||
@@ -93,15 +93,34 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
return NewHandler(handlerCtx, pool, cfg, nil)
|
||||
return NewHandler(handlerCtx, pool, cfg)
|
||||
}
|
||||
|
||||
// testAuthToken is the static bearer token devConfig() configures. There is
|
||||
// no dev-open bypass (removed — plans/2026-07-12-wails-desktop-app.md 0.4),
|
||||
// so every test handler needs a real credential; get/postJSON/do inject it
|
||||
// by default. Pass an explicit "" value for "Authorization" in headers to
|
||||
// test the no-credential path.
|
||||
const testAuthToken = "test-dev-token"
|
||||
|
||||
// applyHeaders sets req's default Authorization header, then layers headers
|
||||
// on top. A "" value deletes the header instead of setting it, so tests can
|
||||
// exercise the missing-credential case.
|
||||
func applyHeaders(req *http.Request, headers map[string]string) {
|
||||
req.Header.Set("Authorization", "Bearer "+testAuthToken)
|
||||
for k, v := range headers {
|
||||
if v == "" {
|
||||
req.Header.Del(k)
|
||||
} else {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
applyHeaders(req, headers)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var body map[string]any
|
||||
@@ -113,6 +132,7 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
applyHeaders(req, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var body map[string]any
|
||||
@@ -122,7 +142,8 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt
|
||||
|
||||
func devConfig() config.Config {
|
||||
c := config.Default()
|
||||
c.APIEnv = "dev" // no tokens → dev-open auth
|
||||
c.APIEnv = "dev"
|
||||
c.APIToken = testAuthToken
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -295,7 +316,7 @@ func TestAPIBearerAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
// API requires the token
|
||||
rec, body := get(t, h, "/api/v1/entities", nil)
|
||||
rec, body := get(t, h, "/api/v1/entities", map[string]string{"Authorization": ""})
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("no token = %d, want 401 (%v)", rec.Code, body)
|
||||
}
|
||||
|
||||
@@ -1290,7 +1290,10 @@ func (s *Server) GetClientContext(ctx context.Context, req gen.GetClientContextR
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if scanErr := rows.Scan(&p); scanErr == nil {
|
||||
if strings.HasPrefix(p, "tools/") && strings.HasSuffix(p, ".setup.sh") {
|
||||
// Matches tools/setup-*.sh (the auto-setup convention —
|
||||
// see tools/post-pull.sh). Was tools/*.setup.sh until
|
||||
// 2026-07-12, which never matched any real filename.
|
||||
if strings.HasPrefix(p, "tools/setup-") && strings.HasSuffix(p, ".sh") {
|
||||
toolsChanged = append(toolsChanged, p)
|
||||
} else if p == ".sops.yaml" {
|
||||
sopsChanged = true
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -107,6 +109,52 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// serveKnowledgeContent returns the full markdown body for a document/
|
||||
// investigation/runbook entity, by its own entity id or slug. Nothing else
|
||||
// exposes knowledge_entities.content — GetEntityKnowledge (below) answers a
|
||||
// different question ("what knowledge references THIS entity"), and
|
||||
// SearchKnowledge only returns a short ts_headline snippet. The KB detail
|
||||
// panel needs the entity's own full content when it IS a knowledge entity.
|
||||
func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
// chi.URLParam returns the raw, still-percent-encoded segment (unlike
|
||||
// the OpenAPI-generated routes, which decode via
|
||||
// runtime.BindStyledParameterWithOptions before reaching the handler) —
|
||||
// slugs like "document:containers/101-jellyfin" arrive as
|
||||
// "document%3Acontainers%2F101-jellyfin" and must be unescaped here.
|
||||
idOrSlug, err := url.PathUnescape(chi.URLParam(req, "id"))
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var title, content, source string
|
||||
var tags []string
|
||||
var updatedAt string
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT ke.title, ke.content, COALESCE(ke.source,''), ke.tags, ke.updated_at::text
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).
|
||||
Scan(&title, &content, &source, &tags, &updatedAt)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"title": title,
|
||||
"content": content,
|
||||
"source": source,
|
||||
"tags": tags,
|
||||
"updated_at": updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledgeRequestObject) (gen.SearchKnowledgeResponseObject, error) {
|
||||
q := request.Params.Q
|
||||
limit := clampLimit(request.Params.Limit)
|
||||
|
||||
@@ -24,9 +24,7 @@ func do(t *testing.T, h http.Handler, method, path string, body any, headers map
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
applyHeaders(req, headers)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var decoded map[string]any
|
||||
|
||||
@@ -154,6 +154,7 @@ func TestPhase4MCPEndpointAlive(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set("Authorization", "Bearer "+testAuthToken)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
"github.com/dtoro/oikos/internal/safego"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -70,7 +72,7 @@ type secretsBackend interface {
|
||||
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
|
||||
// before closing the pool — otherwise the held connection never releases
|
||||
// and pool.Close() deadlocks.
|
||||
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) http.Handler {
|
||||
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler {
|
||||
s := &Server{
|
||||
pool: pool,
|
||||
cfg: cfg,
|
||||
@@ -88,6 +90,12 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(requestLogger)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{cfg.CORSAllowedOrigin},
|
||||
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type", "If-Match"},
|
||||
MaxAge: 86400,
|
||||
}))
|
||||
|
||||
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
|
||||
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
|
||||
@@ -120,6 +128,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
|
||||
}
|
||||
})
|
||||
|
||||
// OIDC endpoints — unauthenticated. The SPA needs the issuer + client_id
|
||||
// to build the authorization URL, and uses the token proxy to exchange
|
||||
// authorization codes and refresh tokens without CORS issues.
|
||||
r.Get("/api/v1/auth/oidc-config", func(w http.ResponseWriter, req *http.Request) {
|
||||
s.serveOIDCConfig(w, req, cfg)
|
||||
})
|
||||
r.Post("/api/v1/auth/oidc-token", func(w http.ResponseWriter, req *http.Request) {
|
||||
s.serveOIDCToken(w, req, cfg)
|
||||
})
|
||||
|
||||
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
|
||||
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
||||
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
|
||||
@@ -130,7 +148,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
|
||||
gen.HandlerWithOptions(strict, gen.ChiServerOptions{
|
||||
BaseURL: "/api/v1",
|
||||
BaseRouter: r,
|
||||
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg)},
|
||||
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg, false)},
|
||||
ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
||||
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
|
||||
},
|
||||
@@ -141,24 +159,32 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
|
||||
// registration wins). The strict-server path can't Flush() per event;
|
||||
// this one uses the real ResponseWriter for real-time delivery. It
|
||||
// inherits the router's base middleware and applies auth via With().
|
||||
r.With(combinedAuth(cfg)).Get("/api/v1/events/stream", s.serveSSE)
|
||||
// allowQueryToken=true: EventSource can't set custom headers, so the
|
||||
// SPA passes the token as ?token=... instead of Authorization.
|
||||
r.With(combinedAuth(cfg, true)).Get("/api/v1/events/stream", s.serveSSE)
|
||||
|
||||
// Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the
|
||||
// Knowledge page's "what the system has learned" view. Registered after
|
||||
// HandlerWithOptions so it wins over any generated catch-all.
|
||||
r.With(combinedAuth(cfg)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
|
||||
|
||||
// Custom (non-OpenAPI) route: full markdown content for a knowledge
|
||||
// entity (document/investigation/runbook) by its own id or slug — the
|
||||
// generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a
|
||||
// different question (knowledge referencing this entity), not this one.
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
|
||||
|
||||
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
||||
// unlike ListExecutions which sorts by target for pagination) and the
|
||||
// per-session "what did this session do" digest.
|
||||
r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity)
|
||||
r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
|
||||
|
||||
// Learning view: capability timeline + success trend, both derived from
|
||||
// executions (real, growing data) rather than the patterns/skills tables,
|
||||
// which are correctly modeled but have no writers anywhere yet.
|
||||
r.With(combinedAuth(cfg)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
|
||||
r.With(combinedAuth(cfg)).Get("/api/v1/learning/trend", s.serveLearningTrend)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/trend", s.serveLearningTrend)
|
||||
|
||||
// Mount MCP at /mcp (plan R3-10)
|
||||
nomosAgentID := uuid.Nil
|
||||
@@ -170,33 +196,30 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
|
||||
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
|
||||
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
|
||||
}
|
||||
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
|
||||
|
||||
r.Get("/ui/*", func(w http.ResponseWriter, req *http.Request) {
|
||||
if uiHandler != nil {
|
||||
uiHandler.ServeHTTP(w, req)
|
||||
}
|
||||
})
|
||||
r.Get("/ui", func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
|
||||
})
|
||||
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
|
||||
})
|
||||
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
|
||||
|
||||
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
|
||||
target, _ := url.Parse(nomosURL)
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
r.Mount("/agent", http.StripPrefix("/agent", proxy))
|
||||
// Was unauthenticated (pre-existing gap, predates the client/server
|
||||
// split — this mount was never wrapped in combinedAuth, unlike every
|
||||
// other custom route below). Harmless while dev-open was in effect;
|
||||
// a real hole now that every route needs a real credential.
|
||||
r.Mount("/agent", combinedAuth(cfg, false)(http.StripPrefix("/agent", proxy)))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// combinedAuth tries OIDC JWT validation first (if configured), falls back to
|
||||
// static bearer token validation, and opens the gate in dev mode when no
|
||||
// credentials are configured.
|
||||
func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
|
||||
// combinedAuth tries OIDC JWT validation first (if configured), then falls
|
||||
// back to static bearer token validation. Every request needs a valid
|
||||
// credential — there is no dev-open bypass (closed as part of the
|
||||
// client/server split, plans/2026-07-12-wails-desktop-app.md 0.4: once the
|
||||
// SPA is a separate client, a dev-open API is reachable from any origin).
|
||||
// When allowQueryToken is set, a missing Authorization header falls back to
|
||||
// a `?token=` query param — only used for the SSE route, since EventSource
|
||||
// can't set custom headers.
|
||||
func combinedAuth(cfg config.Config, allowQueryToken bool) func(http.Handler) http.Handler {
|
||||
hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != ""
|
||||
hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != ""
|
||||
|
||||
@@ -217,31 +240,14 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
var staticTokens [][]byte
|
||||
if cfg.APIToken != "" {
|
||||
staticTokens = append(staticTokens, []byte(cfg.APIToken))
|
||||
}
|
||||
if cfg.MCPBearerToken != "" {
|
||||
staticTokens = append(staticTokens, []byte(cfg.MCPBearerToken))
|
||||
}
|
||||
|
||||
devOpen := cfg.APIEnv == "dev" && !hasStatic && !hasOIDC
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if devOpen {
|
||||
ctx := context.WithValue(r.Context(), actorKey, actor{
|
||||
Type: "system",
|
||||
Label: "dev:anonymous",
|
||||
ID: "dev",
|
||||
TokenType: "none",
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
auth := r.Header.Get("Authorization")
|
||||
raw, ok := strings.CutPrefix(auth, "Bearer ")
|
||||
if (!ok || raw == "") && allowQueryToken {
|
||||
raw = r.URL.Query().Get("token")
|
||||
ok = raw != ""
|
||||
}
|
||||
if !ok || raw == "" {
|
||||
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
|
||||
"missing bearer token")
|
||||
@@ -275,21 +281,10 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
|
||||
|
||||
// Fall back to static tokens
|
||||
if hasStatic {
|
||||
for _, t := range staticTokens {
|
||||
if subtle.ConstantTimeCompare([]byte(raw), t) == 1 {
|
||||
label := "operator:api"
|
||||
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
|
||||
label = "agent:mcp"
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), actorKey, actor{
|
||||
Type: label[:strings.IndexByte(label, ':')],
|
||||
Label: label,
|
||||
ID: raw[:8] + "...",
|
||||
TokenType: "static",
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
if act, ok := staticTokenActor(cfg, raw); ok {
|
||||
ctx := context.WithValue(r.Context(), actorKey, act)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +294,28 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// staticTokenActor validates raw against the configured static bearer
|
||||
// tokens (API token, MCP token) in constant time and returns the resolved
|
||||
// actor. Shared between combinedAuth's header-based check and serveSSE's
|
||||
// query-param check (EventSource can't set custom headers, so the SSE
|
||||
// stream takes the token as ?token=...).
|
||||
func staticTokenActor(cfg config.Config, raw string) (actor, bool) {
|
||||
if raw == "" {
|
||||
return actor{}, false
|
||||
}
|
||||
idPrefix := raw
|
||||
if len(idPrefix) > 8 {
|
||||
idPrefix = idPrefix[:8]
|
||||
}
|
||||
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
|
||||
return actor{Type: "agent", Label: "agent:mcp", ID: idPrefix + "...", TokenType: "static"}, true
|
||||
}
|
||||
if cfg.APIToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.APIToken)) == 1 {
|
||||
return actor{Type: "operator", Label: "operator:api", ID: idPrefix + "...", TokenType: "static"}, true
|
||||
}
|
||||
return actor{}, false
|
||||
}
|
||||
|
||||
// jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
|
||||
// verification, identified by its key ID (kid).
|
||||
type jwtVerificationKey struct {
|
||||
@@ -524,12 +541,128 @@ func requestLogger(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// resolveOIDCEndpointURL derives an endpoint URL from the issuer by walking
|
||||
// up one path segment. Authentik's issuer is per-provider
|
||||
// (e.g. .../application/o/oikos/) but shared endpoints live at the parent
|
||||
// path (.../application/o/<suffix>).
|
||||
func resolveOIDCEndpointURL(issuer, suffix string) string {
|
||||
u, err := url.Parse(issuer)
|
||||
if err != nil {
|
||||
return strings.TrimRight(issuer, "/") + suffix
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/")
|
||||
if idx := strings.LastIndex(u.Path, "/"); idx >= 0 {
|
||||
u.Path = u.Path[:idx]
|
||||
}
|
||||
u.Path += suffix
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// resolveOIDCTokenURL derives the token endpoint URL from the issuer.
|
||||
func resolveOIDCTokenURL(issuer string) string {
|
||||
return resolveOIDCEndpointURL(issuer, "/token/")
|
||||
}
|
||||
|
||||
// serveOIDCConfig returns the OIDC issuer and client_id so the SPA can build
|
||||
// authorization URLs without hardcoding them.
|
||||
func (s *Server) serveOIDCConfig(w http.ResponseWriter, _ *http.Request, cfg config.Config) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": cfg.OIDCIssuer,
|
||||
"client_id": cfg.OIDCClientID,
|
||||
"authorization_endpoint": resolveOIDCEndpointURL(cfg.OIDCIssuer, "/authorize/"),
|
||||
})
|
||||
}
|
||||
|
||||
// tokenExchangeBody mirrors the JSON the SPA sends to the token proxy.
|
||||
type tokenExchangeBody struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
CodeVerifier string `json:"code_verifier,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// serveOIDCToken proxies authorization_code and refresh_token grants to the
|
||||
// OIDC provider's token endpoint. The SPA can't POST directly to Authentik
|
||||
// because of CORS; this proxy avoids the cross-origin problem entirely.
|
||||
func (s *Server) serveOIDCToken(w http.ResponseWriter, req *http.Request, cfg config.Config) {
|
||||
if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
|
||||
writeProblem(w, req, http.StatusServiceUnavailable, "oidc not configured", "")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var tb tokenExchangeBody
|
||||
if err := json.Unmarshal(body, &tb); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid token request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Build the form-encoded body for Authentik's token endpoint
|
||||
form := url.Values{}
|
||||
form.Set("client_id", cfg.OIDCClientID)
|
||||
if cfg.OIDCClientSecret != "" {
|
||||
form.Set("client_secret", cfg.OIDCClientSecret)
|
||||
}
|
||||
|
||||
switch tb.GrantType {
|
||||
case "authorization_code":
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", tb.Code)
|
||||
form.Set("code_verifier", tb.CodeVerifier)
|
||||
form.Set("redirect_uri", tb.RedirectURI)
|
||||
case "refresh_token":
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", tb.RefreshToken)
|
||||
default:
|
||||
writeProblem(w, req, http.StatusBadRequest, "unsupported grant_type", tb.GrantType)
|
||||
return
|
||||
}
|
||||
|
||||
tokenURL := resolveOIDCTokenURL(cfg.OIDCIssuer)
|
||||
client := &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
|
||||
}}
|
||||
resp, err := client.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
slog.Error("oidc token proxy failed", "error", err)
|
||||
writeProblem(w, req, http.StatusBadGateway, "token endpoint unreachable", err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "read token response failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
slog.Warn("oidc token endpoint returned error", "status", resp.StatusCode, "body", string(respBody))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.Write(respBody)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Write(respBody)
|
||||
}
|
||||
|
||||
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
||||
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
||||
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) error {
|
||||
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
|
||||
srv := &http.Server{
|
||||
Addr: cfg.APIListen,
|
||||
Handler: NewHandler(ctx, pool, cfg, uiHandler),
|
||||
Handler: NewHandler(ctx, pool, cfg),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) {
|
||||
|
||||
// Connect to the stream.
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/events/stream", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testAuthToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("connect stream: %v", err)
|
||||
@@ -60,7 +61,10 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) {
|
||||
// POSTing to the SAME live server (same DB → NOTIFY the listener sees).
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
payload, _ := json.Marshal(map[string]any{"slug": "service:sse-rt", "type": "service", "name": "sse-rt"})
|
||||
cResp, err := http.Post(srv.URL+"/api/v1/entities", "application/json", bytes.NewReader(payload))
|
||||
createReq, _ := http.NewRequestWithContext(ctx, "POST", srv.URL+"/api/v1/entities", bytes.NewReader(payload))
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.Header.Set("Authorization", "Bearer "+testAuthToken)
|
||||
cResp, err := http.DefaultClient.Do(createReq)
|
||||
if err != nil {
|
||||
t.Fatalf("trigger create: %v", err)
|
||||
}
|
||||
|
||||
@@ -91,14 +91,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
limit := int(getFloat(args, "limit", 50))
|
||||
return queryRows(ctx, pool, `
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
|
||||
FROM entities e
|
||||
WHERE ($1::text IS NULL OR e.type = $1)
|
||||
AND ($2::text IS NULL OR e.state = $2)
|
||||
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
|
||||
ORDER BY e.slug LIMIT $4`,
|
||||
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil
|
||||
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
|
||||
@@ -149,12 +149,12 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking)",
|
||||
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
|
||||
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
q := nStr(args["query"])
|
||||
return queryRows(ctx, pool, `
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT ke.title, e.slug,
|
||||
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
||||
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
||||
@@ -165,15 +165,15 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.search @@ plainto_tsquery('english', $1)
|
||||
ORDER BY rank DESC
|
||||
LIMIT 20`, q), nil
|
||||
LIMIT 20`, q), "knowledge_results"), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity",
|
||||
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
|
||||
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["entity_slug"].(string)
|
||||
return queryRows(ctx, pool, `
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
||||
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
||||
FROM knowledge_entities ke
|
||||
@@ -193,10 +193,22 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||
WHERE r.valid_to IS NULL
|
||||
AND r.type = 'procedure-for'
|
||||
ORDER BY 1`, slug), nil
|
||||
ORDER BY 1`, slug), "knowledge_results"), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge read it back.",
|
||||
register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
|
||||
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["slug"].(string)
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = $1`, slug), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
|
||||
InputSchema: objSchema(
|
||||
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
|
||||
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
|
||||
@@ -277,7 +289,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
hours := int(getFloat(args, "hours", 24))
|
||||
return queryRows(ctx, pool, `
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT time_bucket('1 hour', ts) AS bucket,
|
||||
entity_id::text, metric,
|
||||
ROUND(avg(value)::numeric, 2) AS avg,
|
||||
@@ -286,7 +298,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
FROM metric_samples
|
||||
WHERE ts > now() - make_interval(hours => $1)
|
||||
GROUP BY bucket, entity_id, metric
|
||||
ORDER BY bucket DESC LIMIT 100`, hours), nil
|
||||
ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
|
||||
})
|
||||
|
||||
// ─── Phase 4: new tools ──────────────────────────────────────────
|
||||
@@ -428,6 +440,23 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
}
|
||||
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`,
|
||||
id, targetID, action+":"+params, correlationID, agentID)
|
||||
pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
|
||||
id, targetID)
|
||||
if sessionID != "" {
|
||||
pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
|
||||
FROM entities t WHERE t.slug = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
|
||||
id, "task:"+sessionID)
|
||||
}
|
||||
|
||||
// Execute reversible actions immediately. restart/pct_exec/systemctl
|
||||
// (outside enable/disable) never reach here — they're routed through
|
||||
@@ -620,14 +649,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
limit := int(getFloat(args, "limit", 50))
|
||||
return queryRows(ctx, pool, `
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
|
||||
entity_id::text, left(input_summary, 200) AS input_summary,
|
||||
left(output_summary, 200) AS output_summary,
|
||||
duration_ms, token_count, success, correlation_id
|
||||
FROM agent_activity
|
||||
WHERE agent_id = $1
|
||||
ORDER BY ts DESC LIMIT $2`, agentID, limit), nil
|
||||
ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
|
||||
})
|
||||
|
||||
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
||||
@@ -635,14 +664,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state",
|
||||
InputSchema: objSchema(),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return queryRows(ctx, pool, `
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
|
||||
e.attributes->>'lan_ip' AS lan_ip,
|
||||
st.health, st.last_check_at
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.type = 'lxc'
|
||||
ORDER BY (e.attributes->>'pve_id')::int`), nil
|
||||
ORDER BY (e.attributes->>'pve_id')::int`), "lxc_list"), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
|
||||
@@ -782,7 +811,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
return textResult("error: hostname required"), nil
|
||||
}
|
||||
slug := "ws:" + hostname
|
||||
return queryRows(ctx, pool, `
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, e.name, e.state,
|
||||
COALESCE(st.health, 'unknown') AS health,
|
||||
COALESCE(st.last_check_at::text, '') AS last_check,
|
||||
@@ -792,7 +821,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.slug = $1
|
||||
ORDER BY e.slug`, slug), nil
|
||||
ORDER BY e.slug`, slug), "entity_card"), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
|
||||
@@ -803,7 +832,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
if slug == "" {
|
||||
return textResult("error: service_slug required"), nil
|
||||
}
|
||||
return queryRows(ctx, pool, `
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, e.name, e.state,
|
||||
COALESCE(st.health, 'unknown') AS health,
|
||||
COALESCE(st.last_check_at::text, '') AS last_check,
|
||||
@@ -811,7 +840,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
COALESCE(e.attributes::text, '{}') AS attrs
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.slug = $1`, slug), nil
|
||||
WHERE e.slug = $1`, slug), "entity_card"), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
|
||||
@@ -849,7 +878,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["entity_slug"].(string)
|
||||
limit := int(getFloat(args, "limit", 20))
|
||||
return queryRows(ctx, pool, `
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
|
||||
al.action, al.method, al.path,
|
||||
al.detail::text AS details
|
||||
@@ -857,13 +886,13 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
JOIN entities e ON e.id = al.entity_id
|
||||
WHERE e.slug = $1
|
||||
ORDER BY al.ts DESC
|
||||
LIMIT $2`, slug, limit), nil
|
||||
LIMIT $2`, slug, limit), "change_log"), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
|
||||
InputSchema: objSchema(),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return queryRows(ctx, pool, `
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, e.state,
|
||||
COALESCE(st.health, 'unknown') AS health,
|
||||
COALESCE(st.last_check_at::text, '') AS last_check
|
||||
@@ -873,7 +902,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
OR st.health IS NOT NULL
|
||||
ORDER BY st.health, e.slug
|
||||
LIMIT 200
|
||||
`), nil
|
||||
`), "fleet_snapshot"), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
|
||||
@@ -1099,6 +1128,26 @@ func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *m
|
||||
return textResult(string(data))
|
||||
}
|
||||
|
||||
func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.CallToolResult {
|
||||
if len(result.Content) == 0 {
|
||||
return result
|
||||
}
|
||||
tc, ok := result.Content[0].(*mcp.TextContent)
|
||||
if !ok || tc.Text == "" {
|
||||
return result
|
||||
}
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Text), &items); err != nil {
|
||||
return result
|
||||
}
|
||||
wrapper := map[string]any{
|
||||
"__renderer": rendererID,
|
||||
"data": items,
|
||||
}
|
||||
data, _ := json.MarshalIndent(wrapper, "", " ")
|
||||
return textResult(string(data))
|
||||
}
|
||||
|
||||
// ─── SSH helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
@@ -1397,6 +1446,23 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
}
|
||||
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
|
||||
id, targetID, actionCol, riskClass, correlationID, agentID)
|
||||
pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
|
||||
id, targetID)
|
||||
if sessionID != "" {
|
||||
pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
|
||||
FROM entities t WHERE t.slug = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
|
||||
id, "task:"+sessionID)
|
||||
}
|
||||
|
||||
if riskClass == policy.RiskReadOnly {
|
||||
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
||||
|
||||
@@ -1,11 +1,79 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func TestAnnotateJSONResult(t *testing.T) {
|
||||
// valid JSON array → wrapped with __renderer + data
|
||||
result := textResult(`[{"slug": "host:hubris", "type": "host"}]`)
|
||||
annotated := annotateJSONResult(result, "entity_card")
|
||||
|
||||
if len(annotated.Content) != 1 {
|
||||
t.Fatalf("expected 1 content item, got %d", len(annotated.Content))
|
||||
}
|
||||
tc, ok := annotated.Content[0].(*mcp.TextContent)
|
||||
if !ok {
|
||||
t.Fatal("content is not TextContent")
|
||||
}
|
||||
|
||||
var wrapper map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.Text), &wrapper); err != nil {
|
||||
t.Fatalf("result is not valid JSON: %v", err)
|
||||
}
|
||||
if wrapper["__renderer"] != "entity_card" {
|
||||
t.Errorf("__renderer = %q, want entity_card", wrapper["__renderer"])
|
||||
}
|
||||
data, ok := wrapper["data"].([]interface{})
|
||||
if !ok || len(data) != 1 {
|
||||
t.Fatal("data is not the original array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateJSONResultNoop(t *testing.T) {
|
||||
// empty content → no-op
|
||||
result := &mcp.CallToolResult{Content: []mcp.Content{}}
|
||||
annotated := annotateJSONResult(result, "entity_card")
|
||||
if len(annotated.Content) != 0 {
|
||||
t.Fatal("empty content should be unchanged")
|
||||
}
|
||||
|
||||
// non-JSON text → no-op (not wrapped)
|
||||
result = textResult("just plain text")
|
||||
annotated = annotateJSONResult(result, "entity_card")
|
||||
tc, _ := annotated.Content[0].(*mcp.TextContent)
|
||||
if strings.Contains(tc.Text, "__renderer") {
|
||||
t.Fatal("non-JSON content should not be annotated")
|
||||
}
|
||||
|
||||
// textResult with empty string → no-op
|
||||
result = textResult("")
|
||||
annotated = annotateJSONResult(result, "entity_card")
|
||||
tc, _ = annotated.Content[0].(*mcp.TextContent)
|
||||
if tc.Text != "" {
|
||||
t.Fatal("empty text content should be unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateJSONResultPreservesMultipleRows(t *testing.T) {
|
||||
result := textResult(`[{"slug": "a"}, {"slug": "b"}, {"slug": "c"}]`)
|
||||
annotated := annotateJSONResult(result, "lxc_list")
|
||||
|
||||
tc, _ := annotated.Content[0].(*mcp.TextContent)
|
||||
var wrapper map[string]interface{}
|
||||
json.Unmarshal([]byte(tc.Text), &wrapper)
|
||||
|
||||
data := wrapper["data"].([]interface{})
|
||||
if len(data) != 3 {
|
||||
t.Fatalf("expected 3 rows in data, got %d", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewServerRegistersTools verifies every tool registers with a valid
|
||||
// input schema. The MCP SDK panics at AddTool if a tool omits its object
|
||||
// input schema, so merely constructing the server exercises that contract —
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate knowledge/wiki/infrastructure/topology.md (Mermaid views) and per-entity context
|
||||
cards from inventory.yaml.
|
||||
|
||||
Views:
|
||||
1. Compute & ingress — hypervisors → guests → services → public URLs
|
||||
2. Storage — mounts and pools per guest
|
||||
|
||||
Context cards (oikos/cards/<name>.md): one compact (~30-line) file per
|
||||
host and service — identity, ontology edges, safe actions + risk class,
|
||||
doc pointer, recent ledger history. This is the token-efficiency layer:
|
||||
an agent orienting on an entity reads one card instead of several
|
||||
search_docs/get_page round-trips.
|
||||
|
||||
Run from the repo root:
|
||||
python3 oikos/gen-topology.py # writes topology.md + cards/
|
||||
python3 oikos/gen-topology.py --check # exit 1 if output would change
|
||||
|
||||
Wired into the same regeneration path as mcp/build_host_files.py so the
|
||||
diagrams and cards never drift from inventory. Edges follow
|
||||
oikos/ontology.yaml (hosts, provides, routes-to, mounts, stores-on).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
print("PyYAML is required: pip install pyyaml", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO))
|
||||
from oikos import gen_topology_lib as lib # noqa: E402
|
||||
from oikos import ledger as oikos_ledger # noqa: E402
|
||||
from oikos import policy as oikos_policy # noqa: E402
|
||||
from oikos import relations as oikos_relations # noqa: E402
|
||||
|
||||
INVENTORY = REPO / "inventory.yaml"
|
||||
OUTPUT = REPO / "knowledge" / "wiki" / "infrastructure" / "topology.md"
|
||||
CARDS_DIR = REPO / "oikos" / "cards"
|
||||
|
||||
BANNER = (
|
||||
"<!-- Generated by oikos/gen-topology.py from inventory.yaml. -->\n"
|
||||
"<!-- Do NOT edit by hand - your changes will be overwritten. -->\n"
|
||||
)
|
||||
|
||||
# View/graph logic lives in oikos/gen_topology_lib.py (importable — this
|
||||
# file's hyphenated name can't be). Re-exported here so existing call
|
||||
# sites in this module don't need a rename.
|
||||
node_id = lib.node_id
|
||||
guest_label = lib.guest_label
|
||||
compute_view = lib.compute_view
|
||||
storage_view = lib.storage_view
|
||||
archaeology_table = lib.archaeology_table
|
||||
|
||||
|
||||
def _host_card(name: str, entry: dict, inv: dict) -> str:
|
||||
lines = [f"# {name} (host:{name})\n"]
|
||||
tag = "LXC" if entry.get("kind") == "lxc" else "VM" if entry.get("kind") == "vm" else entry.get("kind", "")
|
||||
pve = entry.get("pve_id")
|
||||
lines.append(f"- kind: {entry.get('kind', '?')}" + (f" ({tag} {pve})" if pve else ""))
|
||||
lines.append(f"- state: {entry.get('state', 'active')}")
|
||||
if entry.get("host"):
|
||||
lines.append(f"- runs-on: host:{entry['host']}")
|
||||
if entry.get("role"):
|
||||
lines.append(f"- role: {entry['role']}")
|
||||
addr = entry.get("lan_ip", "")
|
||||
mesh = entry.get("mesh", {})
|
||||
mesh_bits = []
|
||||
for m, v in mesh.items():
|
||||
if isinstance(v, dict) and (v.get("ip") or v.get("fqdn")):
|
||||
mesh_bits.append(f"{m}:{v.get('fqdn') or v.get('ip')}")
|
||||
if addr or mesh_bits:
|
||||
lines.append(f"- address: {addr}" + (f" (mesh: {', '.join(mesh_bits)})" if mesh_bits else ""))
|
||||
if entry.get("mounts"):
|
||||
lines.append(f"- mounts: {', '.join(entry['mounts'])}")
|
||||
doc = None
|
||||
if entry.get("kind") == "lxc" and pve:
|
||||
cand = REPO / "knowledge" / "wiki" / "containers" / f"{pve}-{name}.md"
|
||||
if cand.exists():
|
||||
doc = str(cand.relative_to(REPO))
|
||||
elif entry.get("kind") == "vm" and pve:
|
||||
cand = REPO / "knowledge" / "wiki" / "vms" / f"{pve}-{name}.md"
|
||||
if cand.exists():
|
||||
doc = str(cand.relative_to(REPO))
|
||||
elif entry.get("kind") == "proxmox-host":
|
||||
cand = REPO / "knowledge" / "wiki" / "hosts" / f"{name}.md"
|
||||
if cand.exists():
|
||||
doc = str(cand.relative_to(REPO))
|
||||
if doc:
|
||||
lines.append(f"- doc: {doc}")
|
||||
if entry.get("age_pubkey"):
|
||||
lines.append("- secrets: enrolled (age key present)")
|
||||
|
||||
rel = oikos_relations.relations(f"host:{name}", inv)
|
||||
lines.append("\n## Blast radius")
|
||||
lines.append(f"- impacts: {', '.join(rel['impacts']) or '(none)'}")
|
||||
lines.append(f"- affected by: {', '.join(rel['affected_by']) or '(none)'}")
|
||||
if rel["blast_radius"]:
|
||||
lines.append(f"- full blast radius: {', '.join(rel['blast_radius'])}")
|
||||
|
||||
lines.append("\n## Safe actions")
|
||||
lines.append("- see the services this host runs for action-level risk classes")
|
||||
|
||||
hist = oikos_ledger.history(f"host:{name}", limit=5)
|
||||
lines.append("\n## Recent changes")
|
||||
if hist:
|
||||
for h in hist:
|
||||
lines.append(f"- {h.get('ts', '?')} {h.get('action', '?')} ({h.get('risk', '?')}) — {h.get('result', '?')}")
|
||||
else:
|
||||
lines.append("- (none yet)")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _service_card(name: str, entry: dict, inv: dict) -> str:
|
||||
lines = [f"# {name} (service:{name})\n"]
|
||||
if entry.get("backend"):
|
||||
lines.append(f"- backend: host:{entry['backend']}")
|
||||
url = entry.get("url") or entry.get("endpoint")
|
||||
if url:
|
||||
lines.append(f"- url: {url}")
|
||||
if entry.get("doc_page"):
|
||||
lines.append(f"- doc: {entry['doc_page']}")
|
||||
if entry.get("config_repo"):
|
||||
lines.append(f"- config repo: {entry['config_repo']}")
|
||||
if entry.get("risk_notes"):
|
||||
lines.append(f"- risk notes: {entry['risk_notes']}")
|
||||
|
||||
rel = oikos_relations.relations(f"service:{name}", inv)
|
||||
lines.append("\n## Blast radius")
|
||||
lines.append(f"- impacts: {', '.join(rel['impacts']) or '(none)'}")
|
||||
lines.append(f"- affected by: {', '.join(rel['affected_by']) or '(none)'}")
|
||||
|
||||
lines.append("\n## Safe actions")
|
||||
for a in oikos_policy.safe_actions_for_service(name, entry):
|
||||
lines.append(f"- {a['action']} — {a['risk']} (approval: {a['approval']})")
|
||||
|
||||
hist = oikos_ledger.history(f"service:{name}", limit=5)
|
||||
lines.append("\n## Recent changes")
|
||||
if hist:
|
||||
for h in hist:
|
||||
lines.append(f"- {h.get('ts', '?')} {h.get('action', '?')} ({h.get('risk', '?')}) — {h.get('result', '?')}")
|
||||
else:
|
||||
lines.append("- (none yet)")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def generate_cards(inv: dict) -> dict[Path, str]:
|
||||
desired: dict[Path, str] = {}
|
||||
for name, entry in inv.get("hosts", {}).items():
|
||||
desired[CARDS_DIR / f"host-{name}.md"] = _host_card(name, entry, inv)
|
||||
for name, entry in inv.get("services", {}).items():
|
||||
if isinstance(entry, dict):
|
||||
desired[CARDS_DIR / f"service-{name}.md"] = _service_card(name, entry, inv)
|
||||
return desired
|
||||
|
||||
|
||||
def write_cards(inv: dict, check: bool = False) -> int:
|
||||
CARDS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
desired = generate_cards(inv)
|
||||
diff_count = 0
|
||||
for path, content in desired.items():
|
||||
existing = path.read_text() if path.exists() else ""
|
||||
if existing != content:
|
||||
diff_count += 1
|
||||
if not check:
|
||||
path.write_text(content)
|
||||
for existing_path in CARDS_DIR.glob("*.md"):
|
||||
if existing_path not in desired:
|
||||
diff_count += 1
|
||||
if not check:
|
||||
existing_path.unlink()
|
||||
return diff_count
|
||||
|
||||
|
||||
def render(inv: dict) -> str:
|
||||
hosts = inv.get("hosts", {})
|
||||
services = inv.get("services", {})
|
||||
counts = (
|
||||
f"{sum(1 for e in hosts.values() if e.get('kind') == 'proxmox-host')} hypervisors, "
|
||||
f"{sum(1 for e in hosts.values() if e.get('kind') == 'lxc')} LXCs, "
|
||||
f"{sum(1 for e in hosts.values() if e.get('kind') == 'vm')} VMs, "
|
||||
f"{sum(1 for e in hosts.values() if e.get('kind') == 'workstation')} workstations, "
|
||||
f"{len(services)} services"
|
||||
)
|
||||
parts = [
|
||||
BANNER,
|
||||
"# Topology (generated)\n",
|
||||
f"Source: [inventory.yaml](../../../inventory.yaml) — {counts}.",
|
||||
"Edge semantics: [oikos/ontology.yaml](../../../oikos/ontology.yaml). "
|
||||
"Operating model: [OIKOS.md](../../../.agents/OIKOS.md).\n",
|
||||
"## Compute & ingress\n",
|
||||
"\n".join(compute_view(inv)) + "\n",
|
||||
"## Storage (mounts)\n",
|
||||
"\n".join(storage_view(inv)) + "\n",
|
||||
]
|
||||
arch = archaeology_table(inv)
|
||||
if arch:
|
||||
parts += ["## Archaeology (destroyed nodes)\n", "\n".join(arch) + "\n"]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="exit 1 if output would change (don't write)")
|
||||
args = parser.parse_args()
|
||||
|
||||
inv = yaml.safe_load(INVENTORY.read_text())
|
||||
content = render(inv)
|
||||
existing = OUTPUT.read_text() if OUTPUT.exists() else ""
|
||||
topology_changed = existing != content
|
||||
card_diffs = write_cards(inv, check=args.check)
|
||||
|
||||
if args.check:
|
||||
if topology_changed:
|
||||
print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr)
|
||||
if card_diffs:
|
||||
print(f"{card_diffs} card(s) in oikos/cards/ would change", file=sys.stderr)
|
||||
return 1 if (topology_changed or card_diffs) else 0
|
||||
|
||||
if topology_changed:
|
||||
OUTPUT.write_text(content)
|
||||
print(f"wrote {OUTPUT.relative_to(REPO)}")
|
||||
if card_diffs:
|
||||
print(f"wrote/updated {card_diffs} card(s) in oikos/cards/")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,112 +0,0 @@
|
||||
"""oikos/gen_topology_lib.py — shared Mermaid-view logic.
|
||||
|
||||
Split out of oikos/gen-topology.py so it's importable (a hyphenated
|
||||
filename can't be `import`ed as a module). oikos/gen-topology.py is the
|
||||
CLI entrypoint that writes knowledge/wiki/infrastructure/topology.md + oikos/cards/;
|
||||
oikos/console/app.py imports this module directly to render the live
|
||||
/graph page without shelling out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
INVENTORY = REPO / "inventory.yaml"
|
||||
|
||||
|
||||
def load_inventory() -> dict:
|
||||
return yaml.safe_load(INVENTORY.read_text())
|
||||
|
||||
|
||||
def node_id(name: str) -> str:
|
||||
"""Mermaid-safe node id."""
|
||||
return name.replace("-", "_").replace(".", "_").replace("/", "_").strip("_")
|
||||
|
||||
|
||||
def guest_label(name: str, entry: dict) -> str:
|
||||
pve = entry.get("pve_id")
|
||||
role = entry.get("role", "")
|
||||
tag = f"LXC {pve}" if entry.get("kind") == "lxc" and pve else \
|
||||
f"VM {pve}" if entry.get("kind") == "vm" and pve else entry.get("kind", "")
|
||||
ip = entry.get("lan_ip", "")
|
||||
parts = [name, tag, role, ip]
|
||||
return "<br/>".join(str(p) for p in parts if p)
|
||||
|
||||
|
||||
def compute_view(inv: dict) -> list[str]:
|
||||
hosts = inv.get("hosts", {})
|
||||
services = inv.get("services", {})
|
||||
lines = ["```mermaid", "flowchart LR"]
|
||||
|
||||
hypervisors = {n: e for n, e in hosts.items() if e.get("kind") == "proxmox-host"}
|
||||
guests = {n: e for n, e in hosts.items() if e.get("kind") in ("lxc", "vm")}
|
||||
others = {n: e for n, e in hosts.items()
|
||||
if e.get("kind") in ("workstation", "external")}
|
||||
|
||||
for hv in hypervisors:
|
||||
lines.append(f' subgraph {node_id(hv)}_sub["{hv} (Proxmox)"]')
|
||||
for g, e in guests.items():
|
||||
if e.get("host") == hv:
|
||||
lines.append(f' {node_id(g)}["{guest_label(g, e)}"]')
|
||||
lines.append(" end")
|
||||
|
||||
# guests without a parent hypervisor recorded (e.g. rclone)
|
||||
for g, e in guests.items():
|
||||
if e.get("host") not in hypervisors:
|
||||
lines.append(f' {node_id(g)}["{guest_label(g, e)}"]')
|
||||
|
||||
for n, e in others.items():
|
||||
shape = "([{}])" if e.get("kind") == "workstation" else "[[{}]]"
|
||||
lines.append(f' {node_id(n)}{shape.format(guest_label(n, e))}')
|
||||
|
||||
# ingress: public URL -> backend (routes-to)
|
||||
for svc, e in sorted(services.items()):
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
backend = e.get("backend")
|
||||
url = e.get("url") or (
|
||||
f'https://{e["public_host"]}' if e.get("public_host") else None)
|
||||
if backend and url and backend in hosts:
|
||||
host = url.removeprefix("https://").removeprefix("http://")
|
||||
# hypervisors are rendered as subgraphs; point edges at the subgraph id
|
||||
target = node_id(backend) + ("_sub" if backend in hypervisors else "")
|
||||
lines.append(
|
||||
f' {node_id("url_" + svc)}(["{host}"]) -->|routes-to| {target}')
|
||||
|
||||
lines.append("```")
|
||||
return lines
|
||||
|
||||
|
||||
def storage_view(inv: dict) -> list[str]:
|
||||
hosts = inv.get("hosts", {})
|
||||
lines = ["```mermaid", "flowchart LR"]
|
||||
pools: set[str] = set()
|
||||
edges: list[str] = []
|
||||
|
||||
for name, e in hosts.items():
|
||||
for mount in e.get("mounts", []):
|
||||
pools.add(mount)
|
||||
edges.append(f' {node_id(name)}["{name}"] -->|mounts| {node_id(mount)}')
|
||||
|
||||
for pool in sorted(pools):
|
||||
lines.append(f' {node_id(pool)}[("{pool}")]')
|
||||
lines.extend(sorted(set(edges)))
|
||||
lines.append("```")
|
||||
return lines
|
||||
|
||||
|
||||
def archaeology_table(inv: dict) -> list[str]:
|
||||
arch = inv.get("archaeology", {})
|
||||
if not arch:
|
||||
return []
|
||||
lines = ["| Node | ID | Destroyed | Reason |", "|---|---|---|---|"]
|
||||
entries = sorted(arch.items(), key=lambda kv: str(kv[1].get("destroyed", "")),
|
||||
reverse=True)
|
||||
for name, e in entries:
|
||||
lines.append(
|
||||
f'| {name} | {e.get("pve_id", "")} | {e.get("destroyed", "")} '
|
||||
f'| {e.get("reason", "")} |')
|
||||
return lines
|
||||
@@ -4,6 +4,16 @@
|
||||
`signal.acked`/`signal.resolved`/`signal.muted` and `relationship.created`/
|
||||
`relationship.ended` API calls don't emit `observability.Event`, and
|
||||
trusted-proxy header auth for Authentik was never added to `combinedAuth`).
|
||||
**Superseded (2026-07-12):** the embed architecture below (`go:embed
|
||||
all:web/dist`, served at `/ui/`) was removed —
|
||||
[2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md) Phase 0
|
||||
separates the SPA from the `oikos` binary into a standalone static build,
|
||||
served at `/` (no `/ui/` prefix), talking to the API over bearer-token
|
||||
auth (the dev-open bypass mentioned nowhere in this plan was also removed).
|
||||
The trusted-proxy-header gap noted above is moot under the new model — every
|
||||
route requires a real bearer token regardless of what's in front of it. M1-M3
|
||||
and the SPA/component work below are unaffected; only the packaging and auth
|
||||
sections are stale.
|
||||
N0-N3 (Nomos amendment: chat home + sessions), M1
|
||||
(dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte
|
||||
component system), M2 (Operations ledger with approve/deny + cancel, Signals
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# 2026-07-08 — Liveness, drift, and UX cohesion
|
||||
|
||||
**Status:** In Progress — Phases 1–4 code complete; not yet deployed. Phase 5 deferred.
|
||||
(Audited 2026-07-11 — still accurate; prompt caching within Phase 4 also
|
||||
confirmed not implemented.)
|
||||
**Status:** In Progress — Phases 1–4 code complete and now deployed
|
||||
(re-verified 2026-07-12: mac-mini was redeployed from `main` that day for
|
||||
unrelated auth work — plans/2026-07-12-wails-desktop-app.md — which carried
|
||||
every commit up to that point, including this plan's, so "not yet deployed"
|
||||
below is stale). Phase 5 deferred. (Audited 2026-07-11 — still accurate;
|
||||
prompt caching within Phase 4 also confirmed not implemented.)
|
||||
|
||||
- **Phase 1 (drift/staleness):** done. Health/metrics/events misattribution
|
||||
fix, staleness sweep, `/entities` health+freshness, dashboard/fleet-health
|
||||
@@ -26,20 +29,19 @@ confirmed not implemented.)
|
||||
- **Phase 4 (agent efficiency):** core piece done — prior turns' tool
|
||||
calls/results are now replayed into the conversation (previously dropped
|
||||
entirely), and a compact live fleet-health snapshot is injected into the
|
||||
system prompt each turn so the agent starts oriented. Prompt caching and
|
||||
reconsidering the default model are **not done** (lower priority, no
|
||||
measured regression without them).
|
||||
system prompt each turn so the agent starts oriented. Prompt caching is
|
||||
**not done** (lower priority, no measured regression without it).
|
||||
Reconsidering the default model — done, but not by this plan: switched to
|
||||
`deepseek/deepseek-v4-pro` on 2026-07-10 (`cmd/nomos/agent.go:70`) for
|
||||
reliability, per that commit's own comment ("the flash tier over-narrates,
|
||||
occasionally emits canned refusals, and is unreliable at multi-step tool
|
||||
use").
|
||||
- **Phase 5 (CRUD):** `PatchEntity` and a full `/checks` CRUD API
|
||||
(list/create/patch, including enable/disable) already existed server-side;
|
||||
the new Monitoring card's toggle uses `PatchCheck`. **Not done**: a
|
||||
"run check now" endpoint (no scheduler on-demand entrypoint exists yet),
|
||||
relationship editing, and an entity attribute editor UI.
|
||||
|
||||
**Not yet deployed** — the live `oikos-api`/`oikos-scheduler`/nomos
|
||||
containers still run the pre-fix binaries; rebuilding and restarting them
|
||||
needs an explicit go-ahead since it touches the running homelab control
|
||||
plane.
|
||||
|
||||
Addresses five felt problems with the current system: (1) the agent reports
|
||||
stale machine state as if it were fresh, (2) sessions can't be opened and feel
|
||||
disconnected from chat, (3) the Nomos agent re-derives state every turn and
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
# 2026-07-08 — Oikos gaps, broken things, and improvements
|
||||
|
||||
**Status:** In Progress — audited 2026-07-11. Done: A1 (approval FK bug),
|
||||
A3 (Hermes→Nomos help text), D1 (`upsert_knowledge`), D4-partial (general
|
||||
`run` tool). Still open: A2 (notifier flooding/dedup), A4 (`resolveHost`
|
||||
dead code), A5 (`queryRows` stringly-typed columns), A6 (stale
|
||||
`get_state_snapshot` description), B1-B5 (enrollment auth, fake Infisical
|
||||
creds, `/query` mesh-only auth unenforced, insecure host key checking,
|
||||
optional `caller_pubkey`), D2/D3 (no `get_approval_status`/
|
||||
`list_pending_approvals`/signal ack-resolve-mute tools), E (README tool
|
||||
count, Caddyfile placeholders, NOMOS.md duplicate line).
|
||||
**Status:** In Progress — audited 2026-07-11, re-audited 2026-07-12 for
|
||||
drift from the `cmd/hermes`→`cmd/nomos` rename and later fixes. Done: A1
|
||||
(approval FK bug), A3 (Hermes→Nomos help text), **Section C** (toy NLU /
|
||||
silent-wrong-answer fallback — nomos now calls real `listTools()` and
|
||||
routes unmatched queries to `/chat` instead of guessing, per
|
||||
`cmd/nomos/main.go:444-465`), **D.5** (SOUL.md/actuator architecture
|
||||
mismatch — `nomos/SOUL.md:21-22,40` now accurately documents SSH via the
|
||||
`run` tool), D1 (`upsert_knowledge`), D4-partial (general `run` tool).
|
||||
Still open: A2 (notifier flooding/dedup), A4 (`resolveHost` dead code), A5
|
||||
(`queryRows` stringly-typed columns), A6 (stale `get_state_snapshot`
|
||||
description), B1-B5 (enrollment auth, fake Infisical creds, `/query`
|
||||
mesh-only auth unenforced, insecure host key checking, optional
|
||||
`caller_pubkey`), D2/D3 (no `get_approval_status`/`list_pending_approvals`/
|
||||
signal ack-resolve-mute tools), E-partial (Caddyfile placeholders still
|
||||
present; tool count now 33, documented in AGENTS.md as of 2026-07-12).
|
||||
2026-07-12 re-audit also refreshed every `cmd/hermes`→`cmd/nomos` and
|
||||
`internal/mcp/server.go` line-number citation below (the file grew from 28
|
||||
to 33 registered tools since 2026-07-11) — content/status of each finding
|
||||
unchanged, only citations moved.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -78,22 +88,24 @@ named `tools/list`, which doesn't exist. The correct `listTools()` helper
|
||||
|
||||
### A4. `resolveHost` never returns a per-entity SSH user
|
||||
|
||||
`internal/mcp/server.go:943` — the named return `sshUser` is always `""`; the
|
||||
per-entity user branch is dead and everything relies on `sshExec`'s global
|
||||
default fallback. **Fix:** read the SSH user from entity attributes or delete
|
||||
the dead return to make the behavior honest.
|
||||
`internal/mcp/server.go:1222` (was :943 — line moved) — the named return
|
||||
`sshUser` is always `""`; the per-entity user branch is dead and everything
|
||||
relies on `sshExec`'s global default fallback. **Fix:** read the SSH user
|
||||
from entity attributes or delete the dead return to make the behavior
|
||||
honest.
|
||||
|
||||
### A5. `queryRows` stringifies every column
|
||||
|
||||
`internal/mcp/server.go:861` renders all values via `fmt.Sprintf("%v", ...)`,
|
||||
so numbers, bools, timestamps, and JSON all reach agents as strings.
|
||||
**Fix:** type-preserving serialization (pass through pgx-native values into
|
||||
`json.Marshal`) — improves every read tool at once.
|
||||
`internal/mcp/server.go:1090` (was :861 — line moved) renders all values via
|
||||
`fmt.Sprintf("%v", ...)`, so numbers, bools, timestamps, and JSON all reach
|
||||
agents as strings. **Fix:** type-preserving serialization (pass through
|
||||
pgx-native values into `json.Marshal`) — improves every read tool at once.
|
||||
|
||||
### A6. `get_state_snapshot` description is stale
|
||||
|
||||
`internal/mcp/server.go:689` still advertises "disk, drift count" — columns
|
||||
removed in commit 3ea43ad. **Fix:** update the description.
|
||||
`internal/mcp/server.go:863` (was :689 — line moved) still advertises "disk,
|
||||
drift count" — columns removed in commit 3ea43ad. **Fix:** update the
|
||||
description.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,12 +113,13 @@ removed in commit 3ea43ad. **Fix:** update the description.
|
||||
|
||||
### B1. Enrollment is unauthenticated, with a false comment
|
||||
|
||||
`internal/httpapi/server.go:97` says "unauthenticated (IP-gated in handler)"
|
||||
but `EnrollClient` (`internal/httpapi/impl.go:1099`) performs no IP check at
|
||||
all — the only gate is the target entity being in state
|
||||
`planned`/`provisioning`. Caddy's `@enroll` matcher bypasses Authentik.
|
||||
Anyone reaching `oikos.hubris.network` who knows (or guesses) a planned slug
|
||||
receives that node's **age private key** in the HTTP response body.
|
||||
`internal/httpapi/server.go:111` (was :97) says "unauthenticated (IP-gated
|
||||
in handler)" but `EnrollClient` (`internal/httpapi/impl.go:1166`, was
|
||||
:1099) performs no IP check at all — the only gate is the target entity
|
||||
being in state `planned`/`provisioning`. Caddy's `@enroll` matcher bypasses
|
||||
Authentik. Anyone reaching `oikos.hubris.network` who knows (or guesses) a
|
||||
planned slug receives that node's **age private key** in the HTTP response
|
||||
body. Still open — line numbers only, substance unchanged.
|
||||
|
||||
**Fix:** enforce a real gate (mesh-CIDR check, one-time enrollment token
|
||||
minted when the entity is created, or both), and stop returning the age
|
||||
@@ -114,34 +127,49 @@ private key in the response — have the client fetch it from the secret store.
|
||||
|
||||
### B2. Fake Infisical credentials returned to enrollees
|
||||
|
||||
`internal/httpapi/impl.go:1191-1192` returns `"inf_client_"+uuid` /
|
||||
`"inf_secret_"+uuid` — random strings wired to nothing. Enrolled clients hold
|
||||
credentials that authenticate against nothing.
|
||||
`internal/httpapi/impl.go:1260-1261` (was :1191-1192) returns
|
||||
`"inf_client_"+uuid` / `"inf_secret_"+uuid` — random strings wired to
|
||||
nothing. Enrolled clients hold credentials that authenticate against
|
||||
nothing. Still open — line numbers only, substance unchanged.
|
||||
**Fix:** implement `CreateMachineIdentity` in `internal/secrets/infisical.go`,
|
||||
or return no credentials and document the manual step.
|
||||
|
||||
### B3. Hermes `/query` has no auth
|
||||
### B3. Nomos's `/query` has no auth
|
||||
|
||||
`hermes/config.yaml:9` sets `mesh_only: true` but `cmd/hermes/main.go` never
|
||||
reads or enforces it — it serves any caller on :8092, who can invoke
|
||||
`request_execution`. **Fix:** enforce mesh-CIDR (or bearer token) in the
|
||||
handler; fail closed.
|
||||
`nomos/config.yaml:9` (was `hermes/config.yaml:9`) sets `mesh_only: true`
|
||||
but `cmd/nomos/main.go` (was `cmd/hermes/main.go`) never reads or enforces
|
||||
it — it serves any caller on :8092, who can invoke `request_execution`.
|
||||
Still open, now also tracked as C1 in
|
||||
[2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md),
|
||||
deferred by the operator. **Fix:** enforce mesh-CIDR (or bearer token) in
|
||||
the handler; fail closed.
|
||||
|
||||
### B4. SSH host keys not verified
|
||||
|
||||
`ssh.InsecureIgnoreHostKey()` at `internal/mcp/server.go:920`.
|
||||
`ssh.InsecureIgnoreHostKey()` at `internal/mcp/server.go:1155` (was :920).
|
||||
Still open — line number only, substance unchanged.
|
||||
**Fix:** known_hosts pinning (keys are already inventory-managed per node).
|
||||
|
||||
### B5. `list_my_secrets` enumerates all node pubkeys
|
||||
|
||||
Without `caller_pubkey`, `internal/mcp/server.go:709-720` returns every entity
|
||||
that has an `age_pubkey`; nothing ties the caller to what it may list.
|
||||
Without `caller_pubkey`, `internal/mcp/server.go:879-883` (was :709-720)
|
||||
returns every entity that has an `age_pubkey`; nothing ties the caller to
|
||||
what it may list. Still open — line numbers only, substance unchanged.
|
||||
**Fix:** require `caller_pubkey` and scope results to the caller's
|
||||
entitlements.
|
||||
|
||||
---
|
||||
|
||||
## C. User perspective (interacting via Hermes)
|
||||
## C. User perspective (interacting via Hermes) — RESOLVED
|
||||
|
||||
**Resolved as of the Hermes→Nomos rewrite (verified 2026-07-12).** This
|
||||
entire section described `cmd/hermes`, which no longer exists — Hermes was
|
||||
renamed and rebuilt as `cmd/nomos`, a real LLM-backed agent loop, which is
|
||||
exactly the recommendation below. `cmd/nomos/main.go:444-465` now calls the
|
||||
real `listTools()` for "help"/"what can you do", and routes unmatched
|
||||
queries to "natural language queries belong to `/chat`..." instead of
|
||||
silently falling back to `get_health_summary`. Kept below for history —
|
||||
original text unchanged.
|
||||
|
||||
- `routeQuery` NLU is hardcoded `strings.Contains`; `extractEntity`
|
||||
(`cmd/hermes/main.go:173`) recognizes only 5 services (`authentik, caddy,
|
||||
@@ -175,27 +203,41 @@ says 21 — both stale). Missing capabilities:
|
||||
`pending_approval`, an agent has no way to check or reference the approval.
|
||||
Add `get_approval_status` / `list_pending_approvals`.
|
||||
4. Execution actions limited to `restart | systemctl | pct_exec |
|
||||
apt_upgrade` — no deploy/rollback/config-edit path.
|
||||
5. Architecture/doc mismatch: `hermes/SOUL.md` claims "no SSH access; all
|
||||
mutations flow through the actuator", but the MCP server runs
|
||||
`restart`/`pct_exec` synchronously over SSH from inside the api process
|
||||
(`sshExec`, server.go:902). Align docs or move execution to the actuator.
|
||||
apt_upgrade` — no deploy/rollback/config-edit path. Partially
|
||||
superseded: the general `run` MCP tool (D4-partial, done) covers
|
||||
arbitrary commands now; `request_execution`'s fixed enum is still there
|
||||
for the specific actions it names (see
|
||||
[2026-07-10-general-gated-execution.md](2026-07-10-general-gated-execution.md)).
|
||||
5. **RESOLVED (verified 2026-07-12).** Architecture/doc mismatch:
|
||||
`hermes/SOUL.md` claimed "no SSH access; all mutations flow through the
|
||||
actuator", but the MCP server ran `restart`/`pct_exec` synchronously over
|
||||
SSH from inside the api process. `nomos/SOUL.md:21-22,40` now accurately
|
||||
documents SSH access via the policy-gated `run` tool — matches the
|
||||
architecture the general-gated-execution plan built. No longer a
|
||||
mismatch.
|
||||
|
||||
---
|
||||
|
||||
## E. Doc drift / housekeeping
|
||||
|
||||
- Tool counts: README 15 / AGENTS.md 21 / actual 28 — regenerate from
|
||||
`internal/mcp/server.go` (consider a doc-gen make target).
|
||||
- `compose/caddy/Caddyfile.oikos` retains literal `<mac-mini-mesh-ip>`
|
||||
placeholders in all three vhosts.
|
||||
- `.agents/HERMES.md` lists "`inventory.yaml`, `inventory.yaml`" (duplicate).
|
||||
- `plans/index.md` drift: fix-MCP-tools row sat in Active with a broken link
|
||||
after the file moved to `done/` (fixed alongside this plan); TRMNL listed
|
||||
active though in `done/`; Grimmory header says `in-progress` though in
|
||||
`done/`; `.hermes/plans/` (7 executed plans) missing from disk.
|
||||
- `plans/2026-07-05-oikos-prometheus-lxc.md` (~0% done) references deleted
|
||||
`oikos/scheduler.py` and `bin/homelab`; LXC 131 collision unresolved.
|
||||
- **RESOLVED (verified 2026-07-12):** Tool counts. README 15 / AGENTS.md 21
|
||||
/ actual 28 was already stale by 2026-07-11 (registered tools grew to
|
||||
33) — AGENTS.md now documents all 33 with the full catalog (2026-07-12).
|
||||
- **Still open:** `compose/caddy/Caddyfile.oikos` retains literal
|
||||
`<mac-mini-mesh-ip>` placeholders (this repo's copy is a reference only —
|
||||
see [2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md)'s
|
||||
"Plan review" — the real config lives in `dtoro/caddy-conf`).
|
||||
- **RESOLVED:** `.agents/HERMES.md` renamed to `.agents/NOMOS.md`; the
|
||||
duplicate-line bug itself is still present at `.agents/NOMOS.md:11` —
|
||||
only the file citation was stale, the underlying nit is still open.
|
||||
- **RESOLVED (verified 2026-07-12):** `plans/index.md` drift — the broken
|
||||
link, TRMNL/Grimmory Active/Done mismatch, and missing `.hermes/plans/`
|
||||
entries described here are no longer present in the current
|
||||
`plans/index.md`; already fixed sometime after this plan was written.
|
||||
- **RESOLVED (verified 2026-07-12):** `plans/2026-07-05-oikos-prometheus-lxc.md`
|
||||
already self-corrected both the deleted-file references and the LXC 131
|
||||
collision in its own 2026-07-08 changelog — this bullet describes a
|
||||
pre-fix state.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -182,16 +182,27 @@ board reflects reality instead of showing a task that looks perpetually
|
||||
|
||||
### C1. Nomos's own HTTP gateway has zero authentication
|
||||
|
||||
[docker-compose.yml:133](../docker-compose.yml) publishes port 8092 directly
|
||||
[docker-compose.yml:144](../docker-compose.yml) publishes port 8092 directly
|
||||
(`"8092:8092"`, comment: *"mesh-published"*) and
|
||||
[Caddyfile.oikos](../compose/caddy/Caddyfile.oikos:19,34) reverse-proxies to
|
||||
it from two routes. `grep -n "Authorization\|Bearer\|auth" cmd/nomos/main.go`
|
||||
returns **nothing** — `/chat`, `/sessions`, `/sessions/{id}` (including
|
||||
`DELETE`), and `/query` have no credential check of any kind. Anyone who can
|
||||
reach the LAN or mesh network can converse with Nomos directly: start tasks,
|
||||
read/delete any session, answer pending questions, and — via chat-assent —
|
||||
approve gated executions by typing "yes" or "I confirm" to whatever the agent
|
||||
proposes, with no authentication at all. This is the same class of gap
|
||||
[Caddyfile.oikos:52-54](../compose/caddy/Caddyfile.oikos) reverse-proxies to
|
||||
it — as of the client/server split
|
||||
([2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md)), only
|
||||
from `nomos.hubris.network` now, not two routes: `/agent/*` on
|
||||
`oikos.hubris.network` was repointed to go through `api`'s own authenticated
|
||||
proxy mount instead of straight to nomos:8092, but that's `combinedAuth`
|
||||
authenticating the *hop into api*, not anything nomos itself checks — this
|
||||
finding is unaffected by that change, still fully open. `grep -n
|
||||
"Authorization\|Bearer\|auth" cmd/nomos/main.go` still returns **nothing**
|
||||
for nomos's inbound routes (nomos did gain outbound auth as *part of* the
|
||||
client/server split — it now sends `Authorization: Bearer
|
||||
$OIKOS_MCP_BEARER_TOKEN` on its own calls to `api` — but that's the opposite
|
||||
direction from this finding) — `/chat`, `/sessions`, `/sessions/{id}`
|
||||
(including `DELETE`), and `/query` have no credential check of any kind.
|
||||
Anyone who can reach the LAN or mesh network can converse with Nomos
|
||||
directly: start tasks, read/delete any session, answer pending questions,
|
||||
and — via chat-assent — approve gated executions by typing "yes" or "I
|
||||
confirm" to whatever the agent proposes, with no authentication at all. This
|
||||
is the same class of gap
|
||||
[oikos-gaps-and-improvements](2026-07-08-oikos-gaps-and-improvements.md)
|
||||
flagged for the `api`/MCP surface (items B1-B5), but specifically for nomos's
|
||||
*own* port, which doesn't sit behind `combinedAuth` the way `api`'s routes do.
|
||||
|
||||
829
plans/done/2026-07-12-wails-desktop-app.md
Normal file
829
plans/done/2026-07-12-wails-desktop-app.md
Normal file
@@ -0,0 +1,829 @@
|
||||
# 2026-07-12 — Wails desktop application
|
||||
|
||||
**Status:** Done — Phases 0.0–0.6 deployed to production (mac-mini, commit
|
||||
`0c0f35a`, 2026-07-12). Phases 1.0–1.4 implemented (commit `5d6d9e9`,
|
||||
2026-07-13) — pushed to main.
|
||||
|
||||
**Production deploy (2026-07-12):** merged to `main`, picked up by the
|
||||
2-minute deploy poller (`scripts/deploy.sh`: pg_dump backup → rebuild →
|
||||
rolling restart → health check), `healthy after 1s`. Verified post-deploy:
|
||||
unauthenticated `/api/v1/*` now 401s (the dev-open bypass was live in
|
||||
production before this — `OIKOS_ENV=dev` with no token set — so this closed
|
||||
a real, currently-exploitable hole, not just future prep); `/healthz` stayed
|
||||
open; nomos reconnected its MCP session with the new
|
||||
`OIKOS_MCP_BEARER_TOKEN` and a real tool call round-tripped end to end
|
||||
(`get_health_summary` via `/query`). A real random token was generated and
|
||||
added to mac-mini's `.env` (not committed — gitignored) before deploy, so
|
||||
the `${OIKOS_MCP_BEARER_TOKEN:-dev-token}` fallback in `docker-compose.yml`
|
||||
never activated with the weak literal default.
|
||||
|
||||
**Deliberately not done as part of this deploy** (out of scope — a different
|
||||
host/repo than "mac-mini", not touched): the Caddy LXC (121) and
|
||||
`dtoro/caddy-conf`. Checked the real production Caddyfile directly — there is
|
||||
**no `oikos.hubris.network` site block at all yet**, so the Authentik-bypass
|
||||
risk (gap 1 below) doesn't apply yet; there's no public UI exposed to break.
|
||||
`mcp.hubris.network` exists but still reverse-proxies to the old
|
||||
pre-consolidation service on LXC 105 (`192.168.8.205:9810`), unrelated to
|
||||
this stack — stale, but pre-existing and out of scope here. Exposing
|
||||
`oikos.hubris.network` publicly (with the `@api` bypass this plan's
|
||||
Caddyfile.oikos reference copy already has) is unstarted follow-up work, not
|
||||
a regression from this deploy.
|
||||
|
||||
## Plan review — gaps found before starting Phase 0
|
||||
|
||||
Reviewed against the current codebase and the live Caddy topology
|
||||
(`compose/caddy/Caddyfile.oikos`) before writing any code. Six gaps, each
|
||||
with the resolution taken:
|
||||
|
||||
1. **Authentik forward-auth vs. bearer-token clients.** The deployed
|
||||
`oikos.hubris.network` site gates every route (including `/agent/*` and,
|
||||
after this plan, `/api/v1/*`) with `import authentik` — a browser-session
|
||||
forward-auth check, not a header a non-browser client can supply. Closing
|
||||
the dev-open gate (0.4) makes every API route require a bearer token, but
|
||||
says nothing about how a bearer-token client (Wails, curl, a future mobile
|
||||
client) gets past Authentik's login redirect in front of it. Same shape as
|
||||
the existing `@enroll` bypass for `/api/v1/clients/enroll`.
|
||||
**Resolution:** updated the reference copy
|
||||
([Caddyfile.oikos](compose/caddy/Caddyfile.oikos)) with an `@api path
|
||||
/api/v1/* /mcp /agent/*` bypass around `import authentik`, same pattern as
|
||||
`@enroll`, and moved static-SPA serving into the `handle {}` fallback
|
||||
(0.6). This repo's copy is not what's deployed — the real file lives in
|
||||
`dtoro/caddy-conf` and auto-deploys from there — so the equivalent change
|
||||
still needs to land there before a Wails client (or anything else that
|
||||
can't complete Authentik's browser login) can actually reach the API in
|
||||
production. Flagged explicitly as risk #6 below so it isn't discovered the
|
||||
hard way.
|
||||
2. **Nomos's own gateway (C1) is a parallel, unauthenticated path to the same
|
||||
backend.** [2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md)'s
|
||||
C1 finding — nomos's port 8092 has zero auth of its own — is still open.
|
||||
Phase 0.3's CORS/auth work only touches `internal/httpapi` (the `api`
|
||||
process); `cmd/nomos` is untouched. The architecture diagram in this plan
|
||||
shows Caddy's `handle_path /agent/*` proxying straight to `:8092`,
|
||||
bypassing `api`'s `combinedAuth` entirely and relying solely on Authentik.
|
||||
Closing the API's dev-open gate does nothing for this path — nomos's
|
||||
direct mesh-published port (`docker-compose.yml:133`) and
|
||||
`nomos.hubris.network` remain reachable with no credential check at all.
|
||||
**Resolution:** not fixed by this plan — flagged as a pre-existing,
|
||||
independent gap (already tracked as C1) that the Wails desktop app
|
||||
inherits rather than introduces. Added as risk #6 below so it isn't
|
||||
mistaken for something Phase 0 closes.
|
||||
3. **`github.com/go-chi/cors` isn't a dependency yet**, and the plan's sample
|
||||
CORS config (`AllowCredentials: true` with a default `"*"` origin) is
|
||||
spec-invalid — browsers and webviews reject a wildcard
|
||||
`Access-Control-Allow-Origin` when credentials are requested. This API
|
||||
authenticates via `Authorization: Bearer`, not cookies, so credentialed
|
||||
CORS mode isn't needed at all. **Resolution:** drop `AllowCredentials`
|
||||
from the middleware config in 0.3 rather than ship a setting that silently
|
||||
breaks the first time an origin other than `*` is configured.
|
||||
4. **Closing dev-open (0.4) breaks local `docker compose --profile dev up`
|
||||
out of the box** — none of the compose services currently set a token, and
|
||||
today they rely entirely on `OIKOS_ENV=dev` + devOpen. Worse: `cmd/nomos`
|
||||
itself is an unauthenticated client of `api`'s `/mcp` endpoint and
|
||||
`/api/v1/approvals/{id}/decision` (chat-assent approvals) —
|
||||
`grep -rn "Authorization" cmd/nomos/*.go` returned nothing before this
|
||||
fix. Closing dev-open without touching nomos would have broken nomos's own
|
||||
connection to the API, not just local dev ergonomics; this wasn't called
|
||||
out anywhere in the original plan text. **Resolution:** added a `token`
|
||||
field threaded through `mcpClient`/`mcpClientPool` and `agent.apiToken`,
|
||||
both reading `OIKOS_MCP_BEARER_TOKEN` (the same shared secret `api`
|
||||
already validates static tokens against) and sent as `Authorization:
|
||||
Bearer ...` on every request nomos makes to `api`. `docker-compose.yml`
|
||||
sets `OIKOS_MCP_BEARER_TOKEN` (default `dev-token`) on both the `api` and
|
||||
`nomos` services so local dev keeps working.
|
||||
5. **0.2's `const API = apiBase('/api/v1')` pattern bakes in a stale origin.**
|
||||
Module-level constants evaluate once, at import time — before
|
||||
`main.ts`'s `initConfig()` runs (ES module imports are hoisted ahead of a
|
||||
file's own top-level statements) and before `Config.svelte` or a
|
||||
Wails-injected `window.__OIKOS_CONFIG__` can set `apiUrl`. A first-launch
|
||||
Wails webview would resolve `API` to a relative path and try to fetch
|
||||
`wails://.../api/v1/...`, which doesn't exist. **Resolution:** `api.ts`
|
||||
keeps `BASE`/`API` as bare path prefixes (`/agent`, `/api/v1`, never
|
||||
resolved to a URL) and lets `fetchWithAuth` call `apiBase()` fresh on
|
||||
every request — the same fix pattern as gap 4's SSE snippet: resolve at
|
||||
call time, not at module-load time.
|
||||
6. **`api`'s own `/agent` reverse-proxy mount (to nomos) was never behind
|
||||
`combinedAuth` — found while auditing every route for the dev-open
|
||||
removal.** [server.go](../internal/httpapi/server.go)'s
|
||||
`r.Mount("/agent", ...)` was registered directly on the base router,
|
||||
unlike every other custom route (`/mcp`, `/api/v1/knowledge/recent`,
|
||||
etc.), which all use `r.With(combinedAuth(cfg, false))`. Harmless while
|
||||
dev-open made the whole API open anyway; a real hole the moment 0.4 closes
|
||||
it — any request to `api`'s `/agent/*` would reach nomos with no
|
||||
credential check at all, independent of C1 (nomos's *own* gateway on
|
||||
:8092, still open) and independent of gap 1 (Caddy/Authentik). **Resolution:**
|
||||
wrapped the mount in `combinedAuth(cfg, false)`, matching every other
|
||||
route.
|
||||
|
||||
Also: 0.4's local-dev token delivery ended up simpler than described —
|
||||
"Vite injects it into `window.__OIKOS_CONFIG__` at dev time" isn't needed at
|
||||
all for the relative-path dev case. The Vite proxy (0.2) already injects
|
||||
`Authorization: Bearer $OIKOS_API_TOKEN` server-side on every proxied
|
||||
`/api`/`/agent` request, so relative-path fetches during `npm run dev` are
|
||||
authenticated before they leave the dev server — no client-side config
|
||||
needed. `window.__OIKOS_CONFIG__` injection is still exactly what Phase 1's
|
||||
Wails shell needs (absolute URL, no dev proxy to lean on).
|
||||
|
||||
Also: 0.3's SSE-auth snippet checks `GetActor(r.Context()) == nil` *inside*
|
||||
`serveSSE` and validates the query token there — but `serveSSE` only runs
|
||||
after `combinedAuth` has already accepted or rejected the request, and
|
||||
`combinedAuth` requires a header today, so `EventSource` requests (no custom
|
||||
headers) never reach `serveSSE` at all; they 401 in the middleware first.
|
||||
**Actual implementation:** `combinedAuth` itself takes an `allowQueryToken
|
||||
bool`; when set (only for the `/api/v1/events/stream` route) it falls back to
|
||||
`?token=` when the `Authorization` header is absent, before running the same
|
||||
OIDC/static validation as every other route. This reuses all existing auth
|
||||
logic instead of duplicating a static-token-only path inside `serveSSE`, and
|
||||
keeps the gate at the middleware layer rather than half-open inside the
|
||||
handler. The static-token comparison itself was extracted into
|
||||
`staticTokenActor(cfg, raw)`, shared between the header and query-param
|
||||
paths.
|
||||
|
||||
## Goal
|
||||
|
||||
Transform the Oikos control room into a native desktop application using
|
||||
[Wails](https://wails.io), built on top of a clean client/server split. The
|
||||
server (API, MCP, scheduler, notifier, Nomos) stays on the homelab as a
|
||||
long-running service. The client (SPA) is separated from the server binary and
|
||||
deployed independently — any browser talks to the server over HTTP. The Wails
|
||||
app is a thin native client: it loads the same SPA in a webview, configured
|
||||
with the server URL and auth token, and adds system tray, native notifications,
|
||||
auto-start, and auto-update.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Server (homelab, permanent) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ oikos api │ │ oikos sched │ │ oikos notif │ │
|
||||
│ │ :8090 │ │ (observe) │ │ (Matrix) │ │
|
||||
│ │ REST + SSE │ └──────────────┘ └──────────────┘ │
|
||||
│ │ MCP /mcp │ │
|
||||
│ └──────┬───────┘ ┌──────────────┐ │
|
||||
│ │ │ nomos serve │ │
|
||||
│ ├──────────┤ :8092 │ │
|
||||
│ │ MCP │ /agent/* │ │
|
||||
│ │ └──────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ Postgres │ │
|
||||
│ └─────────────┘ │
|
||||
│ │
|
||||
│ Caddy: /api/* → :8090 /agent/* → :8092 /mcp → :8090 │
|
||||
│ / → static SPA (web/dist/) │
|
||||
└───────────────────────┬────────────────────────────────────┘
|
||||
│ HTTPS (bearer auth or OIDC)
|
||||
┌───────────────┼───────────────┐
|
||||
│ │ │
|
||||
┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ Browser │ │ Wails app │ │ CLI/mobile │
|
||||
│ (SPA at /) │ │ (SPA in │ │ (future) │
|
||||
│ │ │ webview) │ │ │
|
||||
└──────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
The existing server roles (`oikos api`, `oikos scheduler`, `oikos notifier`,
|
||||
`nomos serve`, Postgres) run on the homelab mac-mini as systemd services —
|
||||
unchanged. The SPA is no longer embedded in the `oikos` binary; it's a
|
||||
standalone static build served by Caddy at `/`. The API routes (`/api/v1/*`,
|
||||
`/mcp`, `/healthz`) don't conflict with root, and the old root redirect is
|
||||
removed, so no path prefix is needed.
|
||||
|
||||
### Clients
|
||||
|
||||
Any HTTP client that speaks the REST API + bearer auth. The SPA is the
|
||||
canonical client, deployed as static files. The Wails app wraps the same SPA
|
||||
in a native webview. Future clients (CLI, mobile) use the same API.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
**Server:** existing Go code in `internal/` — no changes. `cmd/oikos` removes
|
||||
the SPA embed and `/ui/*` routes. Caddy serves `web/dist/` at `/` with SPA
|
||||
fallback.
|
||||
|
||||
**SPA:** existing Svelte 5 + Vite + Tailwind 4 + shadcn-svelte in `web/`.
|
||||
API base URL and auth token become runtime-configurable. `base: '/'` — no
|
||||
path prefix needed since the SPA is served at root.
|
||||
|
||||
**Desktop:** Wails v3 (Go + webview). The Wails app is a thin shell:
|
||||
- Embeds the SPA as static assets (Wails's `go:embed`-based asset system)
|
||||
- Reads server URL + token from OS keychain at startup, injects into webview
|
||||
- SPA talks to the remote server over HTTPS — same as the browser
|
||||
- No Go backend, no Postgres connection, no bundled sidecars
|
||||
- Native shell: system tray, notifications, auto-start, auto-update,
|
||||
window persistence
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Client/server split
|
||||
|
||||
This phase separates the SPA from the `oikos` binary and makes it a
|
||||
standalone client. The Wails app depends on this split being done first.
|
||||
|
||||
### 0.1 — Remove SPA embed from the server
|
||||
|
||||
- **Delete `web/embed.go`** — the server no longer embeds `web/dist/`.
|
||||
- **`cmd/oikos/main.go`** — remove `uiHandler()` (~35 lines). The
|
||||
`httpapi.ListenAndServe()` signature no longer takes a `uiHandler` param;
|
||||
pass `nil` and handle nil in `server.go`.
|
||||
- **`internal/httpapi/server.go`** — remove the `/ui/*` and `/ui` routes
|
||||
(~15 lines at `server.go:175-182`), and the root redirect to `/ui/`
|
||||
(`server.go:183-185`).
|
||||
- **`web/dist/.gitkeep`** — delete (no longer needed to keep backend-only
|
||||
builds green).
|
||||
- **Dockerfile** — remove the node/ui-builder stage and `COPY --from=` of
|
||||
`web/dist/`. The 3-stage Dockerfile (node → go → runtime) becomes a
|
||||
2-stage build (go → runtime). ~20 lines deleted.
|
||||
|
||||
~80 lines deleted. The `oikos api` binary is now API-only: REST, SSE, MCP,
|
||||
healthz.
|
||||
|
||||
### 0.2 — Make SPA API base URL configurable and add auth interceptor
|
||||
|
||||
The SPA currently hardcodes relative paths and has no auth headers:
|
||||
```ts
|
||||
// web/src/lib/api.ts
|
||||
const BASE = '/agent'
|
||||
const API = '/api/v1'
|
||||
```
|
||||
|
||||
Replace with a runtime-configuration module (`web/src/lib/config.ts`):
|
||||
|
||||
```ts
|
||||
// web/src/lib/config.ts
|
||||
|
||||
interface OikosConfig {
|
||||
apiUrl: string // e.g. "https://oikos.hubris.network"
|
||||
token?: string // bearer token for auth
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OIKOS_CONFIG__?: OikosConfig
|
||||
}
|
||||
}
|
||||
|
||||
let cfg: OikosConfig | undefined
|
||||
|
||||
export function initConfig(override?: OikosConfig) {
|
||||
cfg = override ?? window.__OIKOS_CONFIG__
|
||||
if (cfg?.token) {
|
||||
localStorage.setItem('oikos_token', cfg.token)
|
||||
if (cfg.apiUrl) localStorage.setItem('oikos_api_url', cfg.apiUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): OikosConfig {
|
||||
if (!cfg) {
|
||||
const token = localStorage.getItem('oikos_token')
|
||||
const apiUrl = localStorage.getItem('oikos_api_url')
|
||||
if (token || apiUrl) {
|
||||
cfg = { apiUrl: apiUrl ?? '', token: token ?? undefined }
|
||||
}
|
||||
}
|
||||
return cfg ?? { apiUrl: '' }
|
||||
}
|
||||
|
||||
export function isConfigured(): boolean {
|
||||
const c = getConfig()
|
||||
return !!c.apiUrl && !!c.token
|
||||
}
|
||||
|
||||
// Relative paths are used in dev (Vite proxy) and when the SPA shares an
|
||||
// origin with the API server (Caddy reverse proxy). Absolute paths are used
|
||||
// when the API server is on a different origin (Wails webview, remote access).
|
||||
function apiBase(path: string): string {
|
||||
const c = getConfig()
|
||||
if (!c.apiUrl) return path // relative — relies on same-origin or Vite proxy
|
||||
return `${c.apiUrl}${path}`
|
||||
}
|
||||
|
||||
// ---- Auth fetch wrapper ----
|
||||
// Replaces every raw fetch() call in api.ts. Prepends the API base URL
|
||||
// (absolute when configured, relative when unset for Vite dev proxy) and
|
||||
// adds the Authorization header.
|
||||
|
||||
async function fetchWithAuth(path: string, opts?: RequestInit): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(opts?.headers as Record<string, string> ?? {}),
|
||||
}
|
||||
const c = getConfig()
|
||||
if (c.token) {
|
||||
headers['Authorization'] = `Bearer ${c.token}`
|
||||
}
|
||||
|
||||
return fetch(apiBase(path), { ...opts, headers })
|
||||
}
|
||||
|
||||
// SSE path builder — EventSource doesn't take headers, so pass the token
|
||||
// as a query parameter (the SSE handler in server.go checks it alongside
|
||||
// the Authorization header).
|
||||
export function sseUrl(path: string): string {
|
||||
const c = getConfig()
|
||||
const url = apiBase(path)
|
||||
if (!c.token) return url
|
||||
const sep = url.includes('?') ? '&' : '?'
|
||||
return `${url}${sep}token=${encodeURIComponent(c.token)}`
|
||||
}
|
||||
|
||||
// Export for api.ts to use throughout
|
||||
export { fetchWithAuth, apiBase }
|
||||
```
|
||||
|
||||
Then `web/src/lib/api.ts` — replace every `fetch(...)` call with
|
||||
`fetchWithAuth(...)`. Example:
|
||||
```ts
|
||||
// Before:
|
||||
// const res = await fetch(`${API}/entities?${params}`)
|
||||
// After:
|
||||
import { fetchWithAuth, apiBase } from './config'
|
||||
const API = apiBase('/api/v1')
|
||||
const BASE = apiBase('/agent')
|
||||
// ...
|
||||
const res = await fetchWithAuth(`/api/v1/entities?${params}`)
|
||||
```
|
||||
|
||||
**`web/src/lib/stores/events.ts`** — replace `new EventSource(...)` with
|
||||
`new EventSource(sseUrl(...))`:
|
||||
```ts
|
||||
import { sseUrl } from '$lib/config'
|
||||
|
||||
// Before:
|
||||
// source = new EventSource('/api/v1/events/stream')
|
||||
// After:
|
||||
source = new EventSource(sseUrl('/api/v1/events/stream'))
|
||||
```
|
||||
|
||||
**`web/src/lib/stores/chat.ts`** — the chat SSE is POST + ReadableStream via
|
||||
`fetch()`, which already goes through `streamChat` in `api.ts`. When the plan
|
||||
says "replace every fetch(...) call", `streamChat` is included — the POST to
|
||||
`/agent/chat` becomes `fetchWithAuth('/agent/chat', ...)`.
|
||||
|
||||
**`web/src/lib/stores/context.ts`** — `refreshContext()` calls
|
||||
`fetchDashboardSummary()` and `fetchApprovals()` from `api.ts`. Those
|
||||
already go through `fetchWithAuth`. No change needed here.
|
||||
|
||||
**`web/src/lib/stores/workspace.ts`** — calls `fetchPlan()` and
|
||||
`fetchQuestions()` from `api.ts`. No change needed.
|
||||
|
||||
**`web/src/main.ts`** — call `initConfig()` before mounting the app:
|
||||
```ts
|
||||
import { initConfig, isConfigured } from '$lib/config'
|
||||
|
||||
initConfig()
|
||||
|
||||
const app = mount(isConfigured() ? App : Setup, {
|
||||
target: document.getElementById('app')!
|
||||
})
|
||||
export default app
|
||||
```
|
||||
|
||||
This is the single largest frontend change: ~40 `fetch()` calls spread across
|
||||
`api.ts` (all routes), `stores/events.ts` (EventSource), and the chat stream.
|
||||
Each gets replaced with `fetchWithAuth()` or `sseUrl()`.
|
||||
|
||||
**`web/vite.config.ts`** — `base: '/'` (remove `/ui/` prefix, since the SPA
|
||||
is served at root after the split). The dev proxy stays — same origin in dev
|
||||
means relative paths work. After 0.4 closes the dev-open auth gate, inject
|
||||
the token via a `configure` hook:
|
||||
|
||||
```ts
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), svelte()],
|
||||
base: '/',
|
||||
resolve: {
|
||||
alias: { $lib: '/src/lib' }
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8090',
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
const token = process.env.OIKOS_API_TOKEN
|
||||
if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
'/agent': {
|
||||
target: 'http://localhost:8092',
|
||||
rewrite: (path) => path.replace(/^\/agent/, ''),
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
const token = process.env.OIKOS_API_TOKEN
|
||||
if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 0.3 — Add SSE query-param auth and CORS to the API server
|
||||
|
||||
**SSE auth.** The SSE handler at `/api/v1/events/stream` currently relies on
|
||||
`combinedAuth` middleware for bearer token validation. `EventSource` can't
|
||||
send custom headers, so the SPA passes the token as a query param
|
||||
(`?token=...`). The SSE handler needs to extract and validate it.
|
||||
|
||||
**`internal/httpapi/sse.go`** — in `serveSSE`, before using the context's
|
||||
actor, check for a query-param token:
|
||||
```go
|
||||
func (s *Server) serveSSE(w http.ResponseWriter, r *http.Request) {
|
||||
// If combinedAuth didn't set an actor (no Authorization header —
|
||||
// EventSource can't send one), try the query param.
|
||||
if GetActor(r.Context()) == nil {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token != "" {
|
||||
validateStaticToken(s.cfg, r, token)
|
||||
}
|
||||
}
|
||||
// ... rest of SSE handler
|
||||
}
|
||||
```
|
||||
|
||||
Extract the static-token validation from `combinedAuth` into a shared helper
|
||||
so both the middleware and the SSE handler use the same logic.
|
||||
|
||||
**CORS middleware.** Add CORS to the chi router. This is needed for Wails
|
||||
(webview origin differs from the remote server) and local dev (Vite on
|
||||
`:5173` vs server on `:8090`). For the browser production deployment (Caddy
|
||||
serves both SPA and API from the same origin), it's a no-op.
|
||||
|
||||
**`internal/httpapi/server.go`** — add before the auth middleware:
|
||||
```go
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{cfg.CORSAllowedOrigin},
|
||||
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type", "If-Match"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 86400,
|
||||
}))
|
||||
```
|
||||
|
||||
**`internal/config/config.go`** — add `CORSAllowedOrigin string`, populated
|
||||
from `OIKOS_CORS_ORIGIN`. Default: `"*"` in dev, the Caddy site URL in prod.
|
||||
|
||||
~30 lines added.
|
||||
|
||||
### 0.4 — Auth: close the dev-open gate
|
||||
|
||||
Currently `combinedAuth` opens the gate when `OIKOS_ENV=dev` and no tokens
|
||||
are set (`server.go:228`). After the split, a client from any origin can hit
|
||||
the API — the dev-open path is a security hole.
|
||||
|
||||
- **Remove the `devOpen` path** from `combinedAuth` — every request must
|
||||
carry a valid bearer token (via `Authorization` header or `?token=` query
|
||||
param for SSE).
|
||||
- **For local dev:** set `OIKOS_API_TOKEN=dev-token` and the SPA reads it
|
||||
from `OIKOS_API_TOKEN` env var (Vite injects it into
|
||||
`window.__OIKOS_CONFIG__` at dev time, and the Vite proxy forwards it).
|
||||
- **Browser (production):** the SPA's `Config.svelte` page accepts a static
|
||||
token (stored in `localStorage`). OIDC login flows are a follow-on
|
||||
milestone.
|
||||
- **Desktop (production):** the Wails app reads the token from the OS
|
||||
keychain and injects it into `window.__OIKOS_CONFIG__` before the webview
|
||||
loads.
|
||||
|
||||
### 0.5 — SPA config page (first-launch / setup)
|
||||
|
||||
The SPA needs a page for entering the server URL and auth token on first
|
||||
launch. This page also serves as the foundation for future OIDC login.
|
||||
|
||||
New file `web/src/pages/Config.svelte`:
|
||||
- Two fields: "Server URL" (text input) and "Token" (password input)
|
||||
- "Connect" button: calls `fetchWithAuth('/api/v1/dashboard/summary')` to
|
||||
validate, stores in `localStorage` on success, calls `initConfig()` to
|
||||
refresh runtime config, navigates to `#/overview`
|
||||
- Tabs placeholder for future OIDC flow: "Token", "Login with Authentik"
|
||||
(the second tab is disabled with "coming soon")
|
||||
- Error state: connection failed, wrong token, server unreachable
|
||||
|
||||
`web/src/App.svelte` — check `isConfigured()` at mount. If false, render
|
||||
`Config.svelte` instead of the sidebar. On successful config, transition to
|
||||
the full app.
|
||||
|
||||
`web/src/main.ts` — simplify to always mount `App.svelte` (the config check
|
||||
lives in App.svelte's mount hook, not in main.ts):
|
||||
|
||||
```ts
|
||||
import { mount } from 'svelte'
|
||||
import App from './App.svelte'
|
||||
import './app.css'
|
||||
import { initConfig } from '$lib/config'
|
||||
|
||||
initConfig()
|
||||
mount(App, { target: document.getElementById('app')! })
|
||||
```
|
||||
|
||||
### 0.6 — Deploy SPA as standalone static files
|
||||
|
||||
The SPA is built with `base: '/'` and served by Caddy at `/` with SPA
|
||||
fallback. API routes take priority (explicit `handle_path` blocks in Caddy).
|
||||
|
||||
**Caddy config** (add to the existing `compose/caddy/Caddyfile.oikos`):
|
||||
```
|
||||
handle {
|
||||
root * /var/www/oikos-ui
|
||||
file_server
|
||||
try_files {path} /index.html
|
||||
}
|
||||
```
|
||||
|
||||
The `index.html` doesn't need a placeholder (`__OIKOS_API_URL__`) in the
|
||||
Caddy deployment case — the SPA and API share an origin, so relative paths
|
||||
work and `__OIKOS_CONFIG__` only needs `apiUrl` unset. The token is entered
|
||||
by the user on the Config page and stored in `localStorage`.
|
||||
|
||||
**Build + deploy:**
|
||||
```makefile
|
||||
ui: ## Build the SPA for standalone deployment
|
||||
cd web && npm run build
|
||||
|
||||
deploy-ui: ui ## Deploy SPA to the Caddy host
|
||||
scp -r web/dist/* mac-mini:/var/www/oikos-ui/
|
||||
ssh mac-mini sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
**Dockerfile** (server image, no Node required):
|
||||
```dockerfile
|
||||
# Stage 1: Build Go binary
|
||||
FROM golang:1.26 AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go build -o oikos -tags timetzdata ./cmd/oikos
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=builder /app/oikos /usr/local/bin/oikos
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["oikos"]
|
||||
```
|
||||
|
||||
Drops the node builder stage entirely. The UI is built and deployed
|
||||
separately.
|
||||
|
||||
### 0.7 — Verification
|
||||
|
||||
```
|
||||
# Server
|
||||
oikos api # API + SSE + MCP, no UI
|
||||
curl localhost:8090/healthz # {"status":"ok"}
|
||||
curl -H "Authorization: Bearer dev-token" \
|
||||
localhost:8090/api/v1/dashboard/summary # data
|
||||
|
||||
# SPA (dev)
|
||||
OIKOS_API_TOKEN=dev-token npm run dev # Vite at :5173, proxy to :8090
|
||||
open http://localhost:5173/ # Config page (enter URL + token)
|
||||
# → Overview with live data
|
||||
|
||||
# SPA (production test)
|
||||
cd web && npm run build
|
||||
caddy file-server -root dist --listen :3000 # serve SPA locally
|
||||
# Run oikos api separately, open http://localhost:3000,
|
||||
# enter apiUrl=http://localhost:8090 + token on Config page
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Wails desktop app
|
||||
|
||||
Built on top of the split. The Wails app is a thin native wrapper around the
|
||||
same SPA, configured to talk to the deployed server over HTTPS. No bundled
|
||||
Go server, no Postgres connection, no nomos sidecar.
|
||||
|
||||
### 1.0 — Scaffold and window (1 session)
|
||||
|
||||
Create the Wails project with a native window loading the built SPA.
|
||||
|
||||
**`cmd/desktop/main.go`** — Wails v3 app:
|
||||
|
||||
- On startup: read config from OS keychain (`keyring` package or Wails
|
||||
secrets plugin). Keys: `oikos_server_url`, `oikos_token`.
|
||||
- If no config in keychain: load the SPA anyway — `Config.svelte` handles
|
||||
first-launch setup.
|
||||
- If config exists: inject `window.__OIKOS_CONFIG__` before the webview
|
||||
mounts. Wails v3's `AssetsHandler` can mutate `index.html` before serving:
|
||||
```go
|
||||
assetsHandler: func(ctx context.Context, name string) (string, []byte, error) {
|
||||
if name == "index.html" {
|
||||
b, _ := assets.ReadFile("index.html")
|
||||
html := strings.Replace(string(b),
|
||||
`<script>window.__OIKOS_CONFIG__ = {};</script>`,
|
||||
fmt.Sprintf(`<script>window.__OIKOS_CONFIG__ = %s;</script>`, configJSON),
|
||||
1)
|
||||
return "index.html", []byte(html), nil
|
||||
}
|
||||
b, _ := assets.ReadFile(name)
|
||||
return name, b, nil
|
||||
}
|
||||
```
|
||||
`index.html` includes a placeholder `<script>` tag that gets replaced:
|
||||
```html
|
||||
<script>window.__OIKOS_CONFIG__ = {};</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
```
|
||||
- Window: title "Oikos — Control Room", 1400×900, min 1024×700, dark
|
||||
title bar (`mac.TitleBarStyleHiddenInset` or equivalent dark mode).
|
||||
- Wails embeds `web/dist/` into the binary (via `//go:embed all:dist` in
|
||||
the Wails project or the standard Wails asset system).
|
||||
|
||||
**`cmd/desktop/wails.json`** — Wails project config:
|
||||
```json
|
||||
{
|
||||
"name": "oikos-desktop",
|
||||
"frontend:dir": "../../web",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev"
|
||||
}
|
||||
```
|
||||
|
||||
**`Makefile`**:
|
||||
```makefile
|
||||
desktop: ui ## Build the Wails desktop app
|
||||
wails build -clean -o oikos-desktop
|
||||
```
|
||||
|
||||
**Dev loop for the desktop app:**
|
||||
```sh
|
||||
# Terminal 1: run the server locally (or point at remote)
|
||||
OIKOS_API_TOKEN=dev-token oikos api
|
||||
|
||||
# Terminal 2: start Wails in dev mode (hot-reload, connects to Vite)
|
||||
cd cmd/desktop && wails dev
|
||||
```
|
||||
|
||||
**`web/vite.config.ts`** — add `base: '/'` (already done in 0.2). No
|
||||
Wails-specific Vite config needed since Wails v3 uses the standard Vite dev
|
||||
server.
|
||||
|
||||
**Verify:**
|
||||
```sh
|
||||
OIKOS_SERVER_URL=https://oikos.hubris.network OIKOS_DESKTOP_TOKEN=... make desktop
|
||||
./oikos-desktop
|
||||
# Window opens → Config page (if no keychain entry) or Overview with live data
|
||||
```
|
||||
|
||||
### 1.1 — Native shell features (1 session)
|
||||
|
||||
- **System tray** (Wails v3 `application.NewSystemTray`):
|
||||
- Oikos logo icon (from `web/public/favicon.svg`)
|
||||
- Menu: "Open Control Room" (focus/restore window), "Pending Approvals: N"
|
||||
(fetched via backchannel HTTP call from Go, not the SPA), separator,
|
||||
"Quit"
|
||||
- When window is closed: minimize to tray instead of quitting (set
|
||||
`HideOnClose`)
|
||||
|
||||
- **Native notifications** (`application.Notification`):
|
||||
- A backchannel goroutine polls `GET /api/v1/dashboard/summary` every 30s
|
||||
(separate HTTPS client in Go, independent of the webview)
|
||||
- Fires OS notification when `approvals_pending` or `signals_by_severity.critical`
|
||||
increases since last poll
|
||||
- Click notification → `Window.Restore()` + send a message to the SPA via
|
||||
Wails events to navigate to the relevant page
|
||||
|
||||
- **Window persistence**: remember size/position via Wails v3
|
||||
`window.PersistState` or a JSON file in `~/.config/oikos/window.json`
|
||||
|
||||
- **Auto-start on login** (macOS):
|
||||
- During setup flow, offer a checkbox: "Start automatically on login"
|
||||
- Writes a LaunchAgent plist to `~/Library/LaunchAgents/com.hubris.oikos-desktop.plist`
|
||||
that runs the binary on login
|
||||
- Linux equivalent: `~/.config/autostart/oikos-desktop.desktop`
|
||||
|
||||
**Verify:** Close window → app stays in tray. New approval arrives → OS
|
||||
notification appears with count. Click notification → window opens to Ops
|
||||
page. Restart machine → app opens automatically on login.
|
||||
|
||||
### 1.2 — Token management (1 session)
|
||||
|
||||
- **First launch**: `Config.svelte` prompts for server URL + token (same
|
||||
page as the browser SPA's setup)
|
||||
- On "Connect" success, `Config.svelte` calls a Wails binding
|
||||
`SaveConfig(apiUrl, token)` that stores in the OS keychain:
|
||||
```go
|
||||
func (a *App) SaveConfig(apiUrl string, token string) error {
|
||||
keyring.Set("oikos_server_url", apiUrl)
|
||||
keyring.Set("oikos_token", token)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
- **Subsequent launches**: Wails reads keychain, injects config, SPA skips
|
||||
Config page
|
||||
- **Logout**: "Log out" menu item in system tray clears keychain and
|
||||
refreshes the webview → `Config.svelte` appears
|
||||
|
||||
**Verify:** Enter URL + token on first launch, quit, reopen → skips setup
|
||||
and loads Overview.
|
||||
|
||||
### 1.3 — Auto-update (1 session)
|
||||
|
||||
- Check Gitea releases (or a configured update URL) on startup and every
|
||||
6 hours
|
||||
- Wails v3 update plugin or a custom Go goroutine: `GET /releases/latest` →
|
||||
compare semver → download binary → verify checksum → prompt restart
|
||||
- Update manifest published alongside each release:
|
||||
`oikos-desktop-darwin-arm64.json` with `version`, `url`, `sha256`
|
||||
|
||||
**Verify:** Build v1.0.0, publish v1.0.1 → app detects update, downloads,
|
||||
prompts restart. After restart, version is 1.0.1.
|
||||
|
||||
### 1.4 — Distribution and packaging (1 session)
|
||||
|
||||
- **macOS**: `.app` bundle via `wails build`, code-sign with Apple
|
||||
Developer ID, notarize via `xcrun notarytool`
|
||||
- Bundle ID: `com.hubris.oikos-desktop`
|
||||
- Entitlements: network client, keychain access
|
||||
- **Linux**: `.deb` and AppImage via `wails build` + packaging scripts
|
||||
- **CI**: `.gitea/workflows/desktop.yml` — builds all targets on tag push,
|
||||
uploads artifacts to Gitea releases
|
||||
- **AGENTS.md** update: document the desktop app as a first-class client
|
||||
|
||||
**Verify:** Download `.app` on a fresh Mac, open → first-launch setup →
|
||||
connect to the homelab → full app works with zero dev tools.
|
||||
|
||||
---
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- `internal/` — every package imported as-is. Zero modifications.
|
||||
- `cmd/oikos/` — minus the SPA embed (0.1), the `oikos` binary is unchanged.
|
||||
- `cmd/nomos/` — unchanged. The desktop app talks to nomos through the
|
||||
server's `/agent` reverse proxy — same as the browser.
|
||||
- `web/` — SPA source shared between browser and desktop builds. Gains
|
||||
`config.ts` (auth interceptor), `Config.svelte` (setup page), and
|
||||
`vite.config.ts` drops `/ui/` prefix + adds proxy token injection. All
|
||||
existing pages, components, stores, hooks reused.
|
||||
- `api/openapi.yaml` — unchanged.
|
||||
- `seeds/` — unchanged.
|
||||
- `docker-compose.yml` — server deployment unchanged (minus the Dockerfile
|
||||
losing the UI build stage).
|
||||
|
||||
---
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
1. **Wails v3 maturity.** v3 is newer than v2. Fallback: Wails v2 — same
|
||||
architecture (Go + webview + embedded assets), different Go APIs. Scope
|
||||
of impact: one file (`cmd/desktop/main.go`). The thin-wrapper approach
|
||||
means the Wails API surface is ~50 lines of Go — trivially portable.
|
||||
|
||||
2. **OIDC login flow.** The browser SPA needs an OIDC redirect flow via
|
||||
Authentik for production use (static tokens are fine for homelab dev but
|
||||
not for external access). The `Config.svelte` page has a tab placeholder
|
||||
for this. It's a separate milestone — for now, both browser and desktop
|
||||
clients use a static bearer token configured at first launch.
|
||||
|
||||
3. **SSE query-param token in logs.** The token in `?token=...` appears in
|
||||
Caddy access logs and server request logs. Mitigation: log redaction in
|
||||
Caddy (`log { format filter { wrap json { fields { request>uri replace
|
||||
"token=[^&]*" "token=***" } } } }`) and strip the query param from the
|
||||
request logger in `server.go`.
|
||||
|
||||
4. **Webview CORS for embedded assets.** Wails loads the SPA from `wails://`
|
||||
or `asset://` origin, making cross-origin requests to the remote server.
|
||||
CORS middleware (0.3) handles this. The `Access-Control-Allow-Origin`
|
||||
must match the webview's origin, which may change between Wails versions.
|
||||
Mitigation: allow the configured origin explicitly; fall back to `*` for
|
||||
dev; Wails v3 document its asset origin.
|
||||
|
||||
5. **Multiple clients hitting the same SSE broker.** Browser, Wails app, and
|
||||
Nomos all connect to `/api/v1/events/stream`. The SSE broker already
|
||||
handles multiple subscribers (fan-out via the subscriber list in
|
||||
`sse.go`). Each client gets its own connection and replay. No change
|
||||
needed.
|
||||
|
||||
6. **Deploy-time Caddy changes this plan does not make.** Two changes are
|
||||
required outside this repo before Phase 0's auth tightening actually
|
||||
protects anything in production, both in `dtoro/caddy-conf`:
|
||||
- Add a bearer-token bypass around `import authentik` for `/api/v1/*` and
|
||||
`/mcp` on `oikos.hubris.network`, mirroring the existing `@enroll`
|
||||
bypass — otherwise closing the dev-open gate just adds a second,
|
||||
redundant auth layer behind Authentik's browser-session check, and
|
||||
non-browser clients (Wails, curl) can never get past the first one.
|
||||
- Nomos's gateway (port 8092) has no auth of its own (C1, tracked in
|
||||
[2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md)).
|
||||
Phase 0 does not fix this — the mesh-published port and
|
||||
`nomos.hubris.network` remain open regardless of anything done here.
|
||||
Treat C1 as a co-requisite for a production Wails rollout, not
|
||||
something this plan's auth work incidentally covers.
|
||||
66
plans/done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md
Normal file
66
plans/done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 2026-07-13 — MCP tool apps: custom in-chat renderers
|
||||
|
||||
**Status:** Done — implemented 2026-07-13.
|
||||
|
||||
## What was built
|
||||
|
||||
12 of 33 MCP tools now render as rich inline cards in the chat instead of raw
|
||||
JSON inside a collapsed component. The remaining 21 tools stay collapsed.
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Server** (`internal/mcp/server.go`): `annotateJSONResult()` function wraps
|
||||
`queryRows` output with `{"__renderer": "...", "data": [...]}` for 12 tools.
|
||||
- **Registry** (`web/src/lib/tool-renderers.ts`): match/dispatch system that
|
||||
maps tool names + `__renderer` hints to Svelte components.
|
||||
- **Renderer components** (`web/src/lib/renderers/`): 9 purpose-built cards,
|
||||
each handling loading/spinner, error, and success states with proper ARIA
|
||||
labels.
|
||||
- **Chat dispatch** (`web/src/pages/Chat.svelte`): matched tools render inline
|
||||
before the markdown text, with a 5-card limit to prevent chat spam. Overflow
|
||||
goes to the collapsed `ToolCallGroup` alongside unmatched tools.
|
||||
- **ToolCallGroup** (`web/src/lib/components/ToolCallGroup.svelte`): accepts
|
||||
`unmatched` prop, shows "N tools · M cards shown" when some render inline,
|
||||
hides entirely when all matched.
|
||||
|
||||
### Renderers
|
||||
|
||||
| Component | Tools matched | Visual |
|
||||
|-----------|--------------|--------|
|
||||
| `EntityCard` | `get_entity`, `whoami`, `explain` | Slug, type badge, health dot, key attrs |
|
||||
| `HealthSummary` | `get_health_summary` | Stacked health bar (healthy/degraded/down) |
|
||||
| `LXCList` | `list_lxcs` | Compact table: name, ID, IP, health |
|
||||
| `EntityTable` | `list_entities` | Auto-column table from query results |
|
||||
| `KnowledgeResults` | `search_knowledge`, `get_entity_knowledge` | Title, snippet, source, slug |
|
||||
| `BlastRadius` | `get_blast_radius` | Entities grouped by hop distance |
|
||||
| `ChangeLog` | `get_change_history`, `get_agent_activity` | Timeline with status dots |
|
||||
| `FleetSnapshot` | `get_state_snapshot` | Health + type counts in a grid |
|
||||
| `MetricChart` | `query_metrics` | Bucketed time/avg/min/max table |
|
||||
|
||||
### Tests
|
||||
|
||||
`internal/mcp/server_test.go`: 3 new tests for `annotateJSONResult` — wraps
|
||||
valid JSON arrays, no-op on empty/non-JSON/empty-text content, preserves
|
||||
multi-row arrays.
|
||||
|
||||
### Files changed
|
||||
|
||||
**New (18):**
|
||||
- `web/src/lib/tool-renderers.ts`
|
||||
- `web/src/lib/renderers/index.ts`
|
||||
- `web/src/lib/renderers/EntityCard.svelte` + `entity-card.ts`
|
||||
- `web/src/lib/renderers/HealthSummary.svelte` + `health-summary.ts`
|
||||
- `web/src/lib/renderers/LXCList.svelte` + `lxc-list.ts`
|
||||
- `web/src/lib/renderers/EntityTable.svelte` + `entity-table.ts`
|
||||
- `web/src/lib/renderers/KnowledgeResults.svelte` + `knowledge-results.ts`
|
||||
- `web/src/lib/renderers/BlastRadius.svelte` + `blast-radius.ts`
|
||||
- `web/src/lib/renderers/ChangeLog.svelte` + `change-log.ts`
|
||||
- `web/src/lib/renderers/FleetSnapshot.svelte` + `fleet-snapshot.ts`
|
||||
- `web/src/lib/renderers/MetricChart.svelte` + `metric-chart.ts`
|
||||
|
||||
**Modified (5):**
|
||||
- `internal/mcp/server.go` — `annotateJSONResult()` + 12 tool annotations
|
||||
- `internal/mcp/server_test.go` — 3 tests for `annotateJSONResult`
|
||||
- `web/src/lib/components/ToolCallGroup.svelte` — `unmatched`/`bodyTools`
|
||||
- `web/src/pages/Chat.svelte` — inline dispatch + 5-card limit
|
||||
- `web/src/main.ts` — deferred renderer import
|
||||
@@ -10,7 +10,7 @@ went sideways, open an investigation.
|
||||
| ---- | ----- | ------ |
|
||||
| 2026-07-05 | [Oikos Prometheus LXC](2026-07-05-oikos-prometheus-lxc.md) | Planned — not started |
|
||||
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | In Progress — security items (B1-B5) and doc drift (E) still open |
|
||||
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
|
||||
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress — packaging/auth sections superseded by the Wails plan's Phase 0 (client/server split); M4 still open |
|
||||
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
|
||||
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
|
||||
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
||||
@@ -43,6 +43,8 @@ See [`done/`](done/) for executed plans:
|
||||
| 2026-07-11 | [Concurrent task execution: safety + throughput + frontend correctness](done/2026-07-11-concurrent-task-execution.md) |
|
||||
| 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) |
|
||||
| 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) |
|
||||
| 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) |
|
||||
| 2026-07-13 | [MCP tool apps: custom in-chat renderers](done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md) |
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
31
scripts/deploy/network.hubris.oikos-deploy-poller.plist
Normal file
31
scripts/deploy/network.hubris.oikos-deploy-poller.plist
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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>network.hubris.oikos-deploy-poller</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/sh</string>
|
||||
<string>-c</string>
|
||||
<string>cd $HOME/Projects/oikos && git fetch origin main && SHA_LOCAL=$(git rev-parse HEAD) && SHA_REMOTE=$(git rev-parse origin/main) && if [ "$SHA_LOCAL" != "$SHA_REMOTE" ]; then echo "deploying $SHA_LOCAL -> $SHA_REMOTE" && git pull origin main && scripts/deploy.sh; fi</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HOME</key>
|
||||
<string>/Users/dtoro</string>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/dtoro/Projects/oikos</string>
|
||||
<key>StartInterval</key>
|
||||
<integer>120</integer>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/dtoro/Library/Logs/oikos-deploy-poller.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/dtoro/Library/Logs/oikos-deploy-poller.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
35
scripts/deploy/network.hubris.oikos-deploy-webhook.plist
Normal file
35
scripts/deploy/network.hubris.oikos-deploy-webhook.plist
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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>network.hubris.oikos-deploy-webhook</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/dtoro/Projects/oikos/webhook</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>WEBHOOK_HMAC_SECRET</key>
|
||||
<string>6502524162d6dbc3f6d137000395d401f1837d74ef9bb0a876f8e6bbd65d1ff2</string>
|
||||
<key>WEBHOOK_REPO_DIR</key>
|
||||
<string>/Users/dtoro/Projects/oikos</string>
|
||||
<key>WEBHOOK_LISTEN</key>
|
||||
<string>:9797</string>
|
||||
<key>HOME</key>
|
||||
<string>/Users/dtoro</string>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/dtoro/Projects/oikos</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/dtoro/Library/Logs/oikos-webhook.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/dtoro/Library/Logs/oikos-webhook.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Caveman template renderer — reads template + data JSON files and renders output
|
||||
// Installed automatically via homelab-context post-pull hook
|
||||
const caveman = require("caveman");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: caveman <template> [data.json]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const templatePath = args[0];
|
||||
let data = {};
|
||||
if (args.length > 1) {
|
||||
data = JSON.parse(fs.readFileSync(args[1], "utf8"));
|
||||
}
|
||||
|
||||
const template = fs.readFileSync(templatePath, "utf8");
|
||||
const templateName = path.basename(templatePath, path.extname(templatePath));
|
||||
caveman.register(templateName, template);
|
||||
console.log(caveman.render(templateName, data).trim());
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Caveman + RTK Wrapper - Automated token-efficient output formatting
|
||||
# Installed automatically via homelab-context post-pull hook
|
||||
# Source: https://github.com/adityahimaone/hermes-agent-rtk-caveman
|
||||
# Usage: caveman_wrapper.sh <workflow> [options]
|
||||
|
||||
set -e
|
||||
|
||||
WORKFLOW="$1"
|
||||
shift
|
||||
|
||||
CAVEMAN=~/bin/caveman
|
||||
TEMPLATES_DIR=~/templates
|
||||
DATA_DIR=/tmp/caveman_data
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
case "$WORKFLOW" in
|
||||
git-status)
|
||||
git status --porcelain | awk '
|
||||
BEGIN { staged=0; modified=0; untracked=0; deleted=0 }
|
||||
/^[MARC]./ { staged_arr[staged++] = substr($0, 4) }
|
||||
/^.[MARC]/ { modified_arr[modified++] = substr($0, 4) }
|
||||
/^\?\?/ { untracked_arr[untracked++] = substr($0, 4) }
|
||||
/^D/ || /^.D/ { deleted_arr[deleted++] = substr($0, 4) }
|
||||
END {
|
||||
printf "{"
|
||||
printf "\"staged\":["
|
||||
for(i=0;i<staged;i++) printf "%s\"%s\"", (i>0?",":""), staged_arr[i]
|
||||
printf "],\"modified\":["
|
||||
for(i=0;i<modified;i++) printf "%s\"%s\"", (i>0?",":""), modified_arr[i]
|
||||
printf "],\"untracked\":["
|
||||
for(i=0;i<untracked;i++) printf "%s\"%s\"", (i>0?",":""), untracked_arr[i]
|
||||
printf "],\"deleted\":["
|
||||
for(i=0;i<deleted;i++) printf "%s\"%s\"", (i>0?",":""), deleted_arr[i]
|
||||
printf "]}"
|
||||
}' > "$DATA_DIR/git_status.json"
|
||||
|
||||
if command -v rtk &>/dev/null; then
|
||||
rtk "$CAVEMAN" "$TEMPLATES_DIR/git_status.txt" "$DATA_DIR/git_status.json"
|
||||
else
|
||||
node "$CAVEMAN" "$TEMPLATES_DIR/git_status.txt" "$DATA_DIR/git_status.json"
|
||||
fi
|
||||
;;
|
||||
|
||||
git-log)
|
||||
LIMIT="${1:-10}"
|
||||
git log --oneline -"$LIMIT" --format='{"hash":"%h","author":"%an","date":"%ad","message":"%s"}' --date=short | \
|
||||
jq -s '.' > "$DATA_DIR/git_log.json"
|
||||
jq '{commits: .}' "$DATA_DIR/git_log.json" > "$DATA_DIR/git_log_final.json"
|
||||
if command -v rtk &>/dev/null; then
|
||||
rtk "$CAVEMAN" "$TEMPLATES_DIR/git_log.txt" "$DATA_DIR/git_log_final.json"
|
||||
else
|
||||
node "$CAVEMAN" "$TEMPLATES_DIR/git_log.txt" "$DATA_DIR/git_log_final.json"
|
||||
fi
|
||||
;;
|
||||
|
||||
test-results)
|
||||
TEST_CMD="${1:-npx vitest run}"
|
||||
$TEST_CMD --reporter json 2>/dev/null | \
|
||||
jq '{total: .numTotalTests, passed: .numPassedTests, failed: .numFailedTests, suites: [.testResults[] | {name: .name, status: .status, duration: .duration}]}' > "$DATA_DIR/test_results.json" || true
|
||||
if command -v rtk &>/dev/null; then
|
||||
rtk "$CAVEMAN" "$TEMPLATES_DIR/test_results.txt" "$DATA_DIR/test_results.json"
|
||||
else
|
||||
node "$CAVEMAN" "$TEMPLATES_DIR/test_results.txt" "$DATA_DIR/test_results.json"
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: caveman_wrapper.sh <workflow> [options]"
|
||||
echo " git-status - Compact git status"
|
||||
echo " git-log [limit] - Recent git commits"
|
||||
echo " test-results [cmd] - Compact test results"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,3 +0,0 @@
|
||||
Recent Commits:
|
||||
{{- for d.commits as commit }} {{commit.hash}} {{commit.date}} {{commit.message}}
|
||||
{{- end }}
|
||||
@@ -1,12 +0,0 @@
|
||||
{{- if d.staged }}Staged:
|
||||
{{- for d.staged as file }} + {{file}}
|
||||
{{- end }}{{- end }}
|
||||
{{- if d.modified }}Modified:
|
||||
{{- for d.modified as file }} ~ {{file}}
|
||||
{{- end }}{{- end }}
|
||||
{{- if d.untracked }}Untracked:
|
||||
{{- for d.untracked as file }} ? {{file}}
|
||||
{{- end }}{{- end }}
|
||||
{{- if d.deleted }}Deleted:
|
||||
{{- for d.deleted as file }} - {{file}}
|
||||
{{- end }}{{- end }}
|
||||
@@ -1,4 +0,0 @@
|
||||
{{- if d.failed }}Tests: {{d.passed}}/{{d.total}} passed ({{d.failed}} failed)
|
||||
{{- for d.suites as suite }}{{- if suite.status == "failed" }} {{suite.name}} ({{suite.duration}}ms)
|
||||
{{- end }}{{- end }}{{- else }}All {{d.total}} tests passed
|
||||
{{- end }}
|
||||
@@ -3,7 +3,7 @@
|
||||
# Replaces raw `git pull` in the launchd/systemd timer.
|
||||
# Runs after every git pull to auto-setup tools from the repo.
|
||||
#
|
||||
# Convention: any script at tools/*.setup.sh is sourced/exec'd after pull.
|
||||
# Convention: any script at tools/setup-*.sh is sourced/exec'd after pull.
|
||||
# This lets us ship new tooling to all agent hosts via a simple git push.
|
||||
|
||||
set -euo pipefail
|
||||
@@ -31,7 +31,7 @@ else
|
||||
fi
|
||||
|
||||
# 2. Run any auto-setup scripts
|
||||
for setup_script in "$CONTEXT_DIR"/tools/*.setup.sh; do
|
||||
for setup_script in "$CONTEXT_DIR"/tools/setup-*.sh; do
|
||||
[ -f "$setup_script" ] || continue
|
||||
echo "[post-pull] running $setup_script..."
|
||||
bash "$setup_script" || echo "[post-pull] WARNING: $setup_script exited with code $?"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-caveman.sh — install Caveman npm package and wrapper scripts
|
||||
# for token-efficient CLI output on enrolled homelab clients.
|
||||
set -euo pipefail
|
||||
|
||||
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
|
||||
BIN_DIR="$HOME/bin"
|
||||
TOOLS_DIR="$CLONE_DIR/tools"
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
# Install the caveman npm package globally.
|
||||
if ! command -v caveman >/dev/null 2>&1; then
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
npm install -g caveman 2>/dev/null || true
|
||||
echo "[setup-caveman] caveman npm package installed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Copy wrapper to ~/bin.
|
||||
if [ -f "$TOOLS_DIR/caveman_wrapper.sh" ]; then
|
||||
cp "$TOOLS_DIR/caveman_wrapper.sh" "$BIN_DIR/caveman_wrapper.sh"
|
||||
chmod +x "$BIN_DIR/caveman_wrapper.sh"
|
||||
echo "[setup-caveman] wrapper installed to $BIN_DIR/caveman_wrapper.sh"
|
||||
fi
|
||||
|
||||
# Copy templates.
|
||||
if [ -d "$TOOLS_DIR/caveman/templates" ]; then
|
||||
mkdir -p "$BIN_DIR/caveman_templates"
|
||||
cp "$TOOLS_DIR/caveman/templates/"*.txt "$BIN_DIR/caveman_templates/" 2>/dev/null || true
|
||||
echo "[setup-caveman] templates installed"
|
||||
fi
|
||||
|
||||
echo "[setup-caveman] done"
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-checks.sh — deploy check scripts to /opt/oikos/checks on each host.
|
||||
# Auto-setup hook: tools/*.setup.sh runs after every git pull.
|
||||
# Auto-setup hook: tools/setup-*.sh runs after every git pull.
|
||||
set -euo pipefail
|
||||
|
||||
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-nomos-soul.sh — provision Nomos agent persona.
|
||||
# Copies ~/.nomos/SOUL.md from nomos/SOUL.md. No-op on non-Nomos agents.
|
||||
set -euo pipefail
|
||||
|
||||
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
|
||||
|
||||
if [ -f "$CLONE_DIR/nomos/SOUL.md" ]; then
|
||||
mkdir -p "$HOME/.nomos"
|
||||
cp "$CLONE_DIR/nomos/SOUL.md" "$HOME/.nomos/SOUL.md"
|
||||
echo "[setup-nomos-soul] SOUL.md provisioned"
|
||||
else
|
||||
echo "[setup-nomos-soul] no nomos/SOUL.md found; skipping"
|
||||
fi
|
||||
0
web/dist/.gitkeep
vendored
0
web/dist/.gitkeep
vendored
21
web/embed.go
21
web/embed.go
@@ -1,21 +0,0 @@
|
||||
// Package web embeds the compiled control-room SPA (web/dist) into the oikos
|
||||
// binary, preserving the single-binary deployment (ADR-0001). The dist tree is
|
||||
// produced by `npm run build` (or the Docker ui-builder stage); a committed
|
||||
// web/dist/.gitkeep keeps a backend-only `go build` green when the UI has not
|
||||
// been built.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
// DistFS returns the built SPA rooted at dist/. When the UI has not been built
|
||||
// (only the .gitkeep placeholder is present), Open("index.html") will fail and
|
||||
// the caller serves a 404 — the binary still starts.
|
||||
func DistFS() (fs.FS, error) {
|
||||
return fs.Sub(dist, "dist")
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>window.__OIKOS_CONFIG__ = {};</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && touch dist/.gitkeep",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script lang="ts">
|
||||
import Chat from './pages/Chat.svelte'
|
||||
import Tasks from './pages/Tasks.svelte'
|
||||
import Overview from './pages/Overview.svelte'
|
||||
import Entities from './pages/Entities.svelte'
|
||||
import KnowledgeBase from './pages/KnowledgeBase.svelte'
|
||||
import Ops from './pages/Ops.svelte'
|
||||
import Signals from './pages/Signals.svelte'
|
||||
import Graph from './pages/Graph.svelte'
|
||||
import EntityDetail from './pages/EntityDetail.svelte'
|
||||
import Knowledge from './pages/Knowledge.svelte'
|
||||
import Learning from './pages/Learning.svelte'
|
||||
import Config from './pages/Config.svelte'
|
||||
import { newChat } from '$lib/stores/chat'
|
||||
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
||||
import { connectionState } from '$lib/stores/events'
|
||||
import { isConfigured } from '$lib/config'
|
||||
import { onMount } from 'svelte'
|
||||
import { processPendingCallback, initOIDC } from '$lib/oidc'
|
||||
import * as Sidebar from '$lib/components/ui/sidebar'
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
@@ -20,39 +21,51 @@
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { Toaster } from '$lib/components/ui/sonner'
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
|
||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||
import NetworkIcon from '@lucide/svelte/icons/share-2'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
|
||||
let page = $state('tasks')
|
||||
let page = $state('overview')
|
||||
let routeParam = $state('')
|
||||
let drawerOpen = $state(false)
|
||||
let configured = $state(isConfigured())
|
||||
|
||||
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
||||
const openSignals = $derived(openSignalCount($summary))
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
if (await processPendingCallback()) {
|
||||
configured = true
|
||||
} else if (!configured) {
|
||||
if (await initOIDC()) configured = true
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const path = location.hash.slice(2) || 'tasks'
|
||||
const path = location.hash.slice(2) || 'overview'
|
||||
const [head, ...rest] = path.split('/')
|
||||
page = head || 'tasks'
|
||||
// Entities + Graph were merged into Knowledge Base — keep old links working.
|
||||
if (head === 'entities' || head === 'graph') {
|
||||
location.hash = '#/kb'
|
||||
return
|
||||
}
|
||||
page = head || 'overview'
|
||||
routeParam = rest.join('/')
|
||||
}
|
||||
sync()
|
||||
window.addEventListener('hashchange', sync)
|
||||
const unsubscribeCtx = subscribeContext()
|
||||
return () => window.removeEventListener('hashchange', sync)
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', sync)
|
||||
unsubscribeCtx()
|
||||
}
|
||||
// Context (dashboard summary + approvals poll) and the SSE stream both
|
||||
// authenticate — don't subscribe until a token exists.
|
||||
$effect(() => {
|
||||
if (!configured) return
|
||||
return subscribeContext()
|
||||
})
|
||||
|
||||
function navigate(p: string) {
|
||||
@@ -61,8 +74,7 @@
|
||||
|
||||
const navItems = [
|
||||
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
|
||||
{ id: 'entities', label: 'Entities', icon: DatabaseIcon },
|
||||
{ id: 'graph', label: 'Graph', icon: NetworkIcon },
|
||||
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
|
||||
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
|
||||
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
|
||||
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
||||
@@ -70,6 +82,13 @@
|
||||
]
|
||||
</script>
|
||||
|
||||
{#if !configured}
|
||||
<Config
|
||||
onConnected={() => (configured = true)}
|
||||
onCancel={isConfigured() ? () => (configured = true) : undefined}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
<Toaster />
|
||||
|
||||
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
|
||||
@@ -114,20 +133,10 @@
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={page === 'tasks' || page === 'chat'} onclick={() => navigate('tasks')} tooltipContent="Tasks">
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<ListTodoIcon />
|
||||
<span>Tasks</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{#each navItems as item}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
isActive={page === item.id}
|
||||
isActive={page === item.id || (item.id === 'overview' && page === 'chat')}
|
||||
onclick={() => navigate(item.id)}
|
||||
tooltipContent={item.label}
|
||||
>
|
||||
@@ -158,6 +167,16 @@
|
||||
<PanelRightIcon />
|
||||
<span>Chat drawer</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => (configured = false)}
|
||||
title="Server connection settings"
|
||||
>
|
||||
<SettingsIcon />
|
||||
<span>Connection</span>
|
||||
</Button>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar.Root>
|
||||
|
||||
@@ -166,11 +185,11 @@
|
||||
<Sidebar.Trigger class="-ms-1" />
|
||||
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
||||
{#if page === 'chat'}
|
||||
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('tasks')}>Tasks</button>
|
||||
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Overview</button>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span class="text-base font-medium">Conversation</span>
|
||||
{:else}
|
||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
|
||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page}</span>
|
||||
{/if}
|
||||
<div class="ms-auto flex items-center gap-2.5">
|
||||
{#if $summary}
|
||||
@@ -199,12 +218,8 @@
|
||||
<main class="min-h-0 flex-1 overflow-hidden">
|
||||
{#if page === 'overview'}
|
||||
<Overview />
|
||||
{:else if page === 'tasks'}
|
||||
<Tasks />
|
||||
{:else if page === 'entities'}
|
||||
<Entities />
|
||||
{:else if page === 'graph'}
|
||||
<Graph />
|
||||
{:else if page === 'kb'}
|
||||
<KnowledgeBase />
|
||||
{:else if page === 'entity' && routeParam}
|
||||
<EntityDetail slug={routeParam} />
|
||||
{:else if page === 'ops'}
|
||||
@@ -233,3 +248,5 @@
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { fetchWithAuth } from './config'
|
||||
|
||||
// Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual
|
||||
// origin (relative vs. configured apiUrl) fresh on every call via
|
||||
// config.ts's apiBase(), so these can't be pre-resolved once at module load
|
||||
// (the config may not be known yet at import time, e.g. before Config.svelte
|
||||
// or a Wails-injected __OIKOS_CONFIG__ runs).
|
||||
const BASE = '/agent'
|
||||
const API = '/api/v1'
|
||||
|
||||
@@ -26,21 +33,21 @@ export interface Message {
|
||||
}
|
||||
|
||||
export async function fetchSessions(): Promise<Session[]> {
|
||||
const res = await fetch(`${BASE}/sessions`)
|
||||
const res = await fetchWithAuth(`${BASE}/sessions`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.sessions ?? []
|
||||
}
|
||||
|
||||
export async function fetchMessages(sessionId: string): Promise<Message[]> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}`)
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.messages ?? []
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<boolean> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
return res.ok
|
||||
}
|
||||
|
||||
@@ -57,7 +64,7 @@ export interface PlanStep {
|
||||
}
|
||||
|
||||
export async function fetchPlan(sessionId: string): Promise<PlanStep[]> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}/plan`)
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/plan`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.steps ?? []
|
||||
@@ -74,16 +81,15 @@ export interface SessionQuestion {
|
||||
}
|
||||
|
||||
export async function fetchQuestions(sessionId: string): Promise<SessionQuestion[]> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}/questions`)
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.questions ?? []
|
||||
}
|
||||
|
||||
export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answer })
|
||||
})
|
||||
return res.ok
|
||||
@@ -105,9 +111,8 @@ export function streamChat(
|
||||
): AbortController {
|
||||
const controller = new AbortController()
|
||||
|
||||
fetch(`${BASE}/chat`, {
|
||||
fetchWithAuth(`${BASE}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
|
||||
signal: controller.signal
|
||||
}).then(async (res) => {
|
||||
@@ -161,7 +166,7 @@ export interface DashboardSummary {
|
||||
}
|
||||
|
||||
export async function fetchDashboardSummary(): Promise<DashboardSummary | null> {
|
||||
const res = await fetch(`${API}/dashboard/summary`)
|
||||
const res = await fetchWithAuth(`${API}/dashboard/summary`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -180,11 +185,14 @@ export interface Entity {
|
||||
updated_at: string
|
||||
health?: EntityHealth | null
|
||||
last_check_at?: string | null
|
||||
maintenance_until?: string | null
|
||||
}
|
||||
|
||||
export interface EntityFilters {
|
||||
type?: string
|
||||
state?: string
|
||||
domain?: string
|
||||
layer?: string
|
||||
q?: string
|
||||
}
|
||||
|
||||
@@ -192,14 +200,36 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
|
||||
const params = new URLSearchParams()
|
||||
if (filters.type) params.set('type', filters.type)
|
||||
if (filters.state) params.set('state', filters.state)
|
||||
if (filters.domain) params.set('domain', filters.domain)
|
||||
if (filters.layer) params.set('layer', filters.layer)
|
||||
if (filters.q) params.set('q', filters.q)
|
||||
params.set('limit', '200')
|
||||
const res = await fetch(`${API}/entities?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/entities?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition'
|
||||
|
||||
export interface EntityType {
|
||||
name: string
|
||||
parent_type?: string | null
|
||||
is_abstract: boolean
|
||||
domain: string
|
||||
layer: OntologyLayer
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
// The graph endpoint has no layer param, so callers build a type→layer map from
|
||||
// this to scope the graph client-side (the entities table filters server-side).
|
||||
export async function fetchEntityTypes(): Promise<EntityType[]> {
|
||||
const res = await fetchWithAuth(`${API}/ontology`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.entity_types ?? []
|
||||
}
|
||||
|
||||
export interface EventFilters {
|
||||
type?: string
|
||||
severity?: string
|
||||
@@ -210,7 +240,7 @@ export async function fetchEvents(filters: EventFilters = {}): Promise<import('.
|
||||
if (filters.type) params.set('type', filters.type)
|
||||
if (filters.severity) params.set('severity', filters.severity)
|
||||
params.set('limit', '100')
|
||||
const res = await fetch(`${API}/events?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/events?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -235,7 +265,7 @@ export async function fetchApprovals(status?: string): Promise<Approval[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (status) params.set('status', status)
|
||||
params.set('limit', '200')
|
||||
const res = await fetch(`${API}/approvals?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/approvals?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -246,9 +276,8 @@ export async function decideApproval(
|
||||
decision: 'approve' | 'deny' | 'revoke',
|
||||
note?: string
|
||||
): Promise<Approval | null> {
|
||||
const res = await fetch(`${API}/approvals/${id}/decision`, {
|
||||
const res = await fetchWithAuth(`${API}/approvals/${id}/decision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ decision, note })
|
||||
})
|
||||
if (!res.ok) return null
|
||||
@@ -277,20 +306,20 @@ export async function fetchExecutions(status?: string): Promise<Execution[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (status) params.set('status', status)
|
||||
params.set('limit', '200')
|
||||
const res = await fetch(`${API}/executions?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/executions?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export async function getExecution(id: string): Promise<Execution | null> {
|
||||
const res = await fetch(`${API}/executions/${id}`)
|
||||
const res = await fetchWithAuth(`${API}/executions/${id}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function cancelExecution(id: string): Promise<Execution | null> {
|
||||
const res = await fetch(`${API}/executions/${id}/cancel`, { method: 'POST' })
|
||||
const res = await fetchWithAuth(`${API}/executions/${id}/cancel`, { method: 'POST' })
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -309,7 +338,7 @@ export interface ActivityItem {
|
||||
}
|
||||
|
||||
export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> {
|
||||
const res = await fetch(`${API}/activity/recent?limit=${limit}`)
|
||||
const res = await fetchWithAuth(`${API}/activity/recent?limit=${limit}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -325,7 +354,7 @@ export interface SessionDigest {
|
||||
}
|
||||
|
||||
export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> {
|
||||
const res = await fetch(`${API}/activity/session/${sessionId}`)
|
||||
const res = await fetchWithAuth(`${API}/activity/session/${sessionId}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -338,7 +367,7 @@ export interface CapabilityTimelineItem {
|
||||
}
|
||||
|
||||
export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> {
|
||||
const res = await fetch(`${API}/learning/timeline`)
|
||||
const res = await fetchWithAuth(`${API}/learning/timeline`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -351,7 +380,7 @@ export interface TrendBucket {
|
||||
}
|
||||
|
||||
export async function fetchLearningTrend(): Promise<TrendBucket[]> {
|
||||
const res = await fetch(`${API}/learning/trend`)
|
||||
const res = await fetchWithAuth(`${API}/learning/trend`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -372,7 +401,7 @@ export interface Pattern {
|
||||
}
|
||||
|
||||
export async function fetchPatterns(): Promise<Pattern[]> {
|
||||
const res = await fetch(`${API}/patterns`)
|
||||
const res = await fetchWithAuth(`${API}/patterns`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -390,7 +419,7 @@ export interface Skill {
|
||||
}
|
||||
|
||||
export async function fetchSkills(): Promise<Skill[]> {
|
||||
const res = await fetch(`${API}/skills`)
|
||||
const res = await fetchWithAuth(`${API}/skills`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -418,22 +447,21 @@ export async function fetchSignals(filters: { state?: string; severity?: string
|
||||
if (filters.state) params.set('state', filters.state)
|
||||
if (filters.severity) params.set('severity', filters.severity)
|
||||
params.set('limit', '200')
|
||||
const res = await fetch(`${API}/signals?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/signals?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export async function ackSignal(id: string): Promise<Signal | null> {
|
||||
const res = await fetch(`${API}/signals/${id}/ack`, { method: 'POST' })
|
||||
const res = await fetchWithAuth(`${API}/signals/${id}/ack`, { method: 'POST' })
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function resolveSignal(id: string, note?: string): Promise<Signal | null> {
|
||||
const res = await fetch(`${API}/signals/${id}/resolve`, {
|
||||
const res = await fetchWithAuth(`${API}/signals/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note })
|
||||
})
|
||||
if (!res.ok) return null
|
||||
@@ -441,9 +469,8 @@ export async function resolveSignal(id: string, note?: string): Promise<Signal |
|
||||
}
|
||||
|
||||
export async function muteSignal(id: string, muteUntil: string, note?: string): Promise<Signal | null> {
|
||||
const res = await fetch(`${API}/signals/${id}/mute`, {
|
||||
const res = await fetchWithAuth(`${API}/signals/${id}/mute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mute_until: muteUntil, note })
|
||||
})
|
||||
if (!res.ok) return null
|
||||
@@ -481,7 +508,7 @@ export async function fetchGraph(filters: GraphFilters = {}): Promise<GraphView
|
||||
if (filters.depth) params.set('depth', String(filters.depth))
|
||||
for (const rt of filters.relType ?? []) params.append('rel_type', rt)
|
||||
if (filters.includeStatus) params.append('include', 'status')
|
||||
const res = await fetch(`${API}/graph?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/graph?${params}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -492,14 +519,17 @@ export interface BlastRadiusItem {
|
||||
}
|
||||
|
||||
export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> {
|
||||
const res = await fetch(`${API}/entities/${id}/blast-radius`)
|
||||
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/blast-radius`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
// id is commonly a slug, not a UUID (e.g. "document:infrastructure/network") —
|
||||
// slugs can contain '/', which must be percent-encoded or it splits the path
|
||||
// into extra segments the router won't match.
|
||||
export async function fetchEntity(id: string): Promise<Entity | null> {
|
||||
const res = await fetch(`${API}/entities/${id}`)
|
||||
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -521,7 +551,7 @@ export interface MetricSeries {
|
||||
|
||||
export async function fetchMetrics(entityId: string): Promise<MetricSeries[]> {
|
||||
const params = new URLSearchParams({ entity_id: entityId, rollup: 'auto' })
|
||||
const res = await fetch(`${API}/metrics?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/metrics?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -559,21 +589,38 @@ export interface RecentKnowledge {
|
||||
export async function fetchRecentKnowledge(source?: string): Promise<RecentKnowledge> {
|
||||
const params = new URLSearchParams()
|
||||
if (source) params.set('source', source)
|
||||
const res = await fetch(`${API}/knowledge/recent?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/knowledge/recent?${params}`)
|
||||
if (!res.ok) return { stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] }
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeHit[]> {
|
||||
const res = await fetch(`${API}/knowledge/${entityId}`)
|
||||
const res = await fetchWithAuth(`${API}/knowledge/${entityId}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface KnowledgeContent {
|
||||
title: string
|
||||
content: string
|
||||
source: string
|
||||
tags: string[]
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Full markdown body for a document/investigation/runbook entity — distinct
|
||||
// from fetchEntityKnowledge, which returns knowledge that references OTHER
|
||||
// entities, not this entity's own content.
|
||||
export async function fetchKnowledgeContent(id: string): Promise<KnowledgeContent | null> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
|
||||
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
||||
const res = await fetch(`${API}/events?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/events?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -581,7 +628,7 @@ export async function fetchEntityEvents(entityId: string): Promise<import('./sto
|
||||
|
||||
export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
|
||||
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
||||
const res = await fetch(`${API}/signals?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/signals?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -589,12 +636,50 @@ export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
|
||||
|
||||
export async function fetchEntityExecutions(entityId: string): Promise<Execution[]> {
|
||||
const params = new URLSearchParams({ target: entityId, limit: '50' })
|
||||
const res = await fetch(`${API}/executions?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/executions?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface EntityTask {
|
||||
task: Entity
|
||||
executionCount: number
|
||||
}
|
||||
|
||||
// Executions aren't directly browsable from an entity in a useful way — what
|
||||
// matters is which task/session acted on it, and how many times. There's no
|
||||
// "incoming relationships" endpoint (blast_radius/graph only walks outgoing
|
||||
// edges), so this composes it client-side: find executions targeting this
|
||||
// entity, then check each task's own outgoing edges (task --involves--> entity
|
||||
// directly, or task --involves--> execution) to attribute them. Cheap while
|
||||
// the task count is small; would want a dedicated query if that changes.
|
||||
export async function fetchEntityTasks(entity: Entity): Promise<EntityTask[]> {
|
||||
const [executions, tasks] = await Promise.all([
|
||||
fetchEntityExecutions(entity.id),
|
||||
fetchEntities({ type: 'task' })
|
||||
])
|
||||
const executionIds = new Set(executions.map((e) => e.id))
|
||||
|
||||
const results = await Promise.all(
|
||||
tasks.map(async (task): Promise<EntityTask | null> => {
|
||||
const g = await fetchGraph({ root: task.slug, depth: 1 })
|
||||
if (!g) return null
|
||||
const involvesThisEntity = g.edges.some((e) => e.type === 'involves' && e.target === entity.slug)
|
||||
const nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type]))
|
||||
const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id]))
|
||||
const executionCount = g.edges.filter((e) => {
|
||||
if (e.type !== 'involves') return false
|
||||
const targetId = idBySlug.get(e.target)
|
||||
return targetId != null && nodeTypeById.get(targetId) === 'execution' && executionIds.has(targetId)
|
||||
}).length
|
||||
if (!involvesThisEntity && executionCount === 0) return null
|
||||
return { task, executionCount }
|
||||
})
|
||||
)
|
||||
return results.filter((r): r is EntityTask => r !== null)
|
||||
}
|
||||
|
||||
export interface Check {
|
||||
id: string
|
||||
slug: string
|
||||
@@ -611,16 +696,16 @@ export interface Check {
|
||||
|
||||
export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]> {
|
||||
const params = new URLSearchParams({ target: targetSlug, limit: '50' })
|
||||
const res = await fetch(`${API}/checks?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/checks?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise<Check | null> {
|
||||
const res = await fetch(`${API}/checks/${id}`, {
|
||||
const res = await fetchWithAuth(`${API}/checks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'If-Match': `"${version}"` },
|
||||
headers: { 'If-Match': `"${version}"` },
|
||||
body: JSON.stringify(patch)
|
||||
})
|
||||
if (!res.ok) return null
|
||||
@@ -654,7 +739,7 @@ export async function fetchAgentActivity(filters: {
|
||||
if (filters.activity_type) params.set('activity_type', filters.activity_type)
|
||||
if (filters.entity_id) params.set('entity_id', filters.entity_id)
|
||||
params.set('limit', String(filters.limit ?? 200))
|
||||
const res = await fetch(`${API}/agent-activity?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/agent-activity?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -662,7 +747,7 @@ export async function fetchAgentActivity(filters: {
|
||||
|
||||
export async function searchKnowledge(q: string, limit = 50): Promise<KnowledgeHit[]> {
|
||||
const params = new URLSearchParams({ q, limit: String(limit) })
|
||||
const res = await fetch(`${API}/knowledge/search?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/knowledge/search?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -698,7 +783,7 @@ export async function fetchAudit(filters: {
|
||||
if (filters.action) params.set('action', filters.action)
|
||||
if (filters.correlation_id) params.set('correlation_id', filters.correlation_id)
|
||||
params.set('limit', String(filters.limit ?? 200))
|
||||
const res = await fetch(`${API}/audit?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/audit?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
|
||||
62
web/src/lib/categories.ts
Normal file
62
web/src/lib/categories.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Browsing categories for the Knowledge Base — a coarser, more useful axis
|
||||
// than the ontology's own `layer` (infrastructure/governance/cognition),
|
||||
// which lumps very different things (an LXC and a DNS record and a storage
|
||||
// volume) into one "infrastructure" bucket. Built from the ontology's
|
||||
// `domain` field instead, which already draws these lines; this just
|
||||
// groups the 9 domains into 6 browsing-sized buckets.
|
||||
import type { EntityFilters } from './api'
|
||||
|
||||
export type Category = 'network' | 'fleet' | 'services' | 'storage' | 'identity' | 'knowledge'
|
||||
|
||||
export const categories: { id: Category; label: string }[] = [
|
||||
{ id: 'fleet', label: 'Fleet' },
|
||||
{ id: 'network', label: 'Network' },
|
||||
{ id: 'services', label: 'Services' },
|
||||
{ id: 'storage', label: 'Storage' },
|
||||
{ id: 'identity', label: 'Identity' },
|
||||
{ id: 'knowledge', label: 'Knowledge' }
|
||||
]
|
||||
|
||||
// entity_types.domain -> Category. `external` folds into Network (isp-link,
|
||||
// domain-registration are network-adjacent); `physical` folds into Fleet
|
||||
// (ups/sensor/site support compute, browsing them separately fragments
|
||||
// "what's running where"). `meta` (the abstract root "entity" type) and
|
||||
// `cognition` (see KNOWLEDGE_TYPES below) are handled outside this map.
|
||||
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
|
||||
network: 'network',
|
||||
external: 'network',
|
||||
compute: 'fleet',
|
||||
physical: 'fleet',
|
||||
software: 'services',
|
||||
storage: 'storage',
|
||||
identity: 'identity'
|
||||
}
|
||||
|
||||
// `cognition` is not one thing: document/investigation/runbook are genuine
|
||||
// long-form knowledge, but the domain also holds execution/check/task/
|
||||
// signal/approval/pattern/skill/classification/feedback — operational
|
||||
// telemetry with its own pages (Operations, Signals, Learning). Mapping the
|
||||
// whole domain to Knowledge pulled in 245 execution + 25 check entities that
|
||||
// fan out to a handful of compute nodes via `targets`/`checks` edges,
|
||||
// flooding the graph. Only the true knowledge types get a category; the
|
||||
// rest are excluded from Knowledge Base browsing entirely (returns
|
||||
// undefined, same treatment as the abstract `entity` root type).
|
||||
const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook'])
|
||||
|
||||
export function typeToCategory(type: string, domain: string): Category | undefined {
|
||||
if (KNOWLEDGE_TYPES.has(type)) return 'knowledge'
|
||||
if (domain === 'cognition') return undefined
|
||||
return DOMAIN_TO_CATEGORY[domain]
|
||||
}
|
||||
|
||||
// Filter sets to fetch and merge for a category's table view. Most
|
||||
// categories are one or two `domain` values; Knowledge is a handful of
|
||||
// specific `type`s carved out of the (otherwise excluded) cognition domain.
|
||||
export function filtersForCategory(category: Category): EntityFilters[] {
|
||||
if (category === 'knowledge') {
|
||||
return Array.from(KNOWLEDGE_TYPES).map((type) => ({ type }))
|
||||
}
|
||||
return Object.entries(DOMAIN_TO_CATEGORY)
|
||||
.filter(([, c]) => c === category)
|
||||
.map(([domain]) => ({ domain }))
|
||||
}
|
||||
34
web/src/lib/components/DetailSection.svelte
Normal file
34
web/src/lib/components/DetailSection.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import * as Collapsible from '$lib/components/ui/collapsible'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let {
|
||||
title,
|
||||
count,
|
||||
defaultOpen,
|
||||
children
|
||||
}: {
|
||||
title: string
|
||||
count?: number
|
||||
defaultOpen: boolean
|
||||
children: Snippet
|
||||
} = $props()
|
||||
|
||||
let open = $state(defaultOpen)
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open class="rounded-md border bg-card">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2.5 py-1.5 text-left hover:bg-muted/50">
|
||||
<span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
|
||||
<ChevronDownIcon
|
||||
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in">
|
||||
<div class="border-t px-2.5 py-2">
|
||||
{@render children()}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
@@ -2,14 +2,17 @@
|
||||
import { onMount, tick } from 'svelte'
|
||||
import uPlot from 'uplot'
|
||||
import 'uplot/dist/uPlot.min.css'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {
|
||||
fetchEntity,
|
||||
fetchGraph,
|
||||
fetchMetrics,
|
||||
fetchEntityEvents,
|
||||
fetchEntitySignals,
|
||||
fetchEntityExecutions,
|
||||
fetchEntityTasks,
|
||||
fetchEntityKnowledge,
|
||||
fetchKnowledgeContent,
|
||||
fetchChecksForTarget,
|
||||
fetchAgentActivity,
|
||||
fetchAudit,
|
||||
@@ -21,29 +24,33 @@
|
||||
type Relationship,
|
||||
type MetricSeries,
|
||||
type Signal,
|
||||
type Execution,
|
||||
type EntityTask,
|
||||
type KnowledgeHit,
|
||||
type KnowledgeContent,
|
||||
type Check,
|
||||
type AgentActivity,
|
||||
type AuditEntry
|
||||
} from '$lib/api'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import { relativeTime, truncateMiddle } from '$lib/utils'
|
||||
import type { OikosEvent } from '$lib/stores/events'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import DetailSection from '$lib/components/DetailSection.svelte'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
||||
let { slug }: { slug: string } = $props()
|
||||
const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook'])
|
||||
|
||||
let { slug, onSelectEntity }: { slug: string; onSelectEntity?: (slug: string) => void } = $props()
|
||||
|
||||
let entity = $state<Entity | null>(null)
|
||||
let relations = $state<Relationship[]>([])
|
||||
let metrics = $state<MetricSeries[]>([])
|
||||
let events = $state<OikosEvent[]>([])
|
||||
let signals = $state<Signal[]>([])
|
||||
let executions = $state<Execution[]>([])
|
||||
let tasks = $state<EntityTask[]>([])
|
||||
let knowledge = $state<KnowledgeHit[]>([])
|
||||
let ownContent = $state<KnowledgeContent | null>(null)
|
||||
let checks = $state<Check[]>([])
|
||||
let agentActivity = $state<AgentActivity[]>([])
|
||||
let auditEntries = $state<AuditEntry[]>([])
|
||||
@@ -58,13 +65,14 @@
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
const [graphView, m, ev, sig, exec, kh, ch, aa, au] = await Promise.all([
|
||||
const [graphView, m, ev, sig, tk, kh, oc, ch, aa, au] = await Promise.all([
|
||||
fetchGraph({ root: entity.id, depth: 1 }),
|
||||
fetchMetrics(entity.id),
|
||||
fetchEntityEvents(entity.id),
|
||||
fetchEntitySignals(entity.id),
|
||||
fetchEntityExecutions(entity.id),
|
||||
fetchEntityTasks(entity),
|
||||
fetchEntityKnowledge(entity.id),
|
||||
KNOWLEDGE_TYPES.has(entity.type) ? fetchKnowledgeContent(entity.id) : Promise.resolve(null),
|
||||
fetchChecksForTarget(entity.slug),
|
||||
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
|
||||
fetchAudit({ entity_id: entity.id, limit: 50 })
|
||||
@@ -73,8 +81,9 @@
|
||||
metrics = m
|
||||
events = ev
|
||||
signals = sig
|
||||
executions = exec
|
||||
tasks = tk
|
||||
knowledge = kh
|
||||
ownContent = oc
|
||||
checks = ch
|
||||
agentActivity = aa
|
||||
auditEntries = au
|
||||
@@ -174,37 +183,117 @@
|
||||
toast.error('Failed to update check')
|
||||
}
|
||||
}
|
||||
|
||||
// Attributes are freeform (no attribute_schema on most entity types), so
|
||||
// the generic key/value list was truncating anything long — including a
|
||||
// document's whole changelog — to an unreadable single line. Recognize a
|
||||
// few common shapes and render them properly instead of hiding them.
|
||||
interface ChangelogEntry {
|
||||
date?: string
|
||||
title?: string
|
||||
body?: string
|
||||
}
|
||||
|
||||
const LONG_TEXT_KEYS = new Set(['description', 'content', 'summary', 'notes', 'note', 'body', 'details'])
|
||||
|
||||
function isChangelog(value: unknown): value is ChangelogEntry[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v))
|
||||
)
|
||||
}
|
||||
|
||||
function isFlatObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value as object).every((v) => v === null || typeof v !== 'object')
|
||||
)
|
||||
}
|
||||
|
||||
type AttributeRow =
|
||||
| { key: string; kind: 'long-text'; value: string }
|
||||
| { key: string; kind: 'changelog'; value: ChangelogEntry[] }
|
||||
| { key: string; kind: 'flat-object'; value: Record<string, unknown> }
|
||||
| { key: string; kind: 'simple'; value: unknown }
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
|
||||
}
|
||||
|
||||
function classifyAttributes(attrs: Record<string, unknown>): AttributeRow[] {
|
||||
return Object.entries(attrs).map(([key, value]): AttributeRow => {
|
||||
if (typeof value === 'string' && (LONG_TEXT_KEYS.has(key) || value.length > 120)) {
|
||||
return { key, kind: 'long-text', value }
|
||||
}
|
||||
if (isChangelog(value)) return { key, kind: 'changelog', value }
|
||||
if (isFlatObject(value)) return { key, kind: 'flat-object', value }
|
||||
return { key, kind: 'simple', value }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="@container flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
|
||||
<div class="flex h-full flex-col gap-2 overflow-y-auto p-3 md:p-4">
|
||||
{#if loading}
|
||||
<Skeleton class="h-8 w-48" />
|
||||
<div class="grid grid-cols-1 gap-4 @lg:grid-cols-2">
|
||||
<Skeleton class="h-40 w-full" />
|
||||
<Skeleton class="h-40 w-full" />
|
||||
</div>
|
||||
<Skeleton class="h-6 w-48" />
|
||||
<Skeleton class="h-8 w-full" />
|
||||
<Skeleton class="h-8 w-full" />
|
||||
<Skeleton class="h-8 w-full" />
|
||||
{:else if !entity}
|
||||
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="font-mono text-lg font-semibold">{entity.slug}</h1>
|
||||
<Badge variant="outline">{entity.type}</Badge>
|
||||
{#if entity.state}<Badge>{entity.state}</Badge>{/if}
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5 text-xs text-muted-foreground" title="{entity.health} — checked {relativeTime(entity.last_check_at)}">
|
||||
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
|
||||
{entity.health} · checked {relativeTime(entity.last_check_at)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h1 class="font-mono text-sm font-semibold">{entity.slug}</h1>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Monitoring ({checks.length})</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1.5">
|
||||
{#snippet detailsContent()}
|
||||
<dl class="flex flex-col gap-1 text-xs">
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">Type</dt>
|
||||
<dd><Badge variant="outline">{entity.type}</Badge></dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">State</dt>
|
||||
<dd>{#if entity.state}<Badge>{entity.state}</Badge>{:else}<span class="text-muted-foreground">—</span>{/if}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">Health</dt>
|
||||
<dd>
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5" title="checked {relativeTime(entity.last_check_at)}">
|
||||
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
|
||||
{entity.health} · checked {relativeTime(entity.last_check_at)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">not monitored</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">Version</dt>
|
||||
<dd>{entity.version}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">Created</dt>
|
||||
<dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 {entity.maintenance_until ? 'border-b pb-1' : ''}">
|
||||
<dt class="shrink-0 text-muted-foreground">Updated</dt>
|
||||
<dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
|
||||
</div>
|
||||
{#if entity.maintenance_until}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="shrink-0 text-muted-foreground">Maintenance until</dt>
|
||||
<dd>{new Date(entity.maintenance_until).toLocaleString()}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
{/snippet}
|
||||
|
||||
{#snippet monitoringContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each checks as check (check.id)}
|
||||
<div class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs">
|
||||
<div class="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="font-mono">{check.kind}</Badge>
|
||||
<span class="text-muted-foreground">every {check.interval_s}s</span>
|
||||
@@ -221,202 +310,321 @@
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No checks configured for this entity.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Attributes</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if entity.attributes && Object.keys(entity.attributes).length}
|
||||
<dl class="flex flex-col gap-1.5 text-xs">
|
||||
{#each Object.entries(entity.attributes) as [key, value]}
|
||||
<div class="flex items-start justify-between gap-3 border-b pb-1.5 last:border-0">
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
|
||||
<dd class="min-w-0 flex-1 truncate text-right">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</dd>
|
||||
{#snippet contentContent()}
|
||||
{#if ownContent}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
<div class="prose-chat max-w-none text-xs">{@html renderMarkdown(ownContent.content)}</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No content.</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet attributesContent()}
|
||||
{#if entity.attributes && Object.keys(entity.attributes).length}
|
||||
{@const rows = classifyAttributes(entity.attributes)}
|
||||
<div class="flex flex-col gap-2 text-xs">
|
||||
{#each rows as row (row.key)}
|
||||
{#if row.kind === 'long-text'}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="font-mono text-muted-foreground">{row.key}</p>
|
||||
<p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">{row.value}</p>
|
||||
</div>
|
||||
{:else if row.kind === 'changelog'}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="font-mono text-muted-foreground">{row.key} ({row.value.length})</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each row.value as entry}
|
||||
<div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
{#if entry.date}<span class="shrink-0 font-mono text-muted-foreground">{entry.date}</span>{/if}
|
||||
{#if entry.title}<span class="font-medium">{entry.title}</span>{/if}
|
||||
</div>
|
||||
{#if entry.body}<p class="whitespace-pre-wrap break-words text-muted-foreground">{entry.body}</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No attributes.</p>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Relations ({relations.length})</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each relations as rel}
|
||||
<div class="flex items-center gap-1 font-mono text-xs">
|
||||
<span>{rel.source}</span>
|
||||
<span class="text-muted-foreground">—{rel.type}→</span>
|
||||
<span>{rel.target}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
||||
</div>
|
||||
{:else if row.kind === 'flat-object'}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="font-mono text-muted-foreground">{row.key}</p>
|
||||
<dl class="flex flex-col gap-0.5 rounded-md bg-muted/40 p-1.5">
|
||||
{#each Object.entries(row.value) as [subKey, subValue]}
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{subKey}</dt>
|
||||
<dd class="min-w-0 flex-1 break-words text-right">{String(subValue)}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
|
||||
<dd class="min-w-0 flex-1 break-words text-right">
|
||||
{#if row.value !== null && typeof row.value === 'object'}
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(row.value, null, 2)}</pre>
|
||||
{:else}
|
||||
{String(row.value)}
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No attributes.</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if metrics.length}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Metrics</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
{#snippet relationsContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each relations as rel}
|
||||
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
|
||||
{#if onSelectEntity}
|
||||
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
|
||||
{:else}
|
||||
<span class="min-w-0 shrink truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet metricsContent()}
|
||||
{#if metrics.length}
|
||||
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
{#each metrics as series (series.metric)}
|
||||
<div>
|
||||
<p class="mb-1 text-xs text-muted-foreground">{series.metric} ({series.rollup})</p>
|
||||
<div bind:this={chartContainers[series.metric]}></div>
|
||||
</div>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No metrics tracked.</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Signals</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-2">
|
||||
{#each signals as signal (signal.id)}
|
||||
<div class="flex flex-col gap-1 border-b pb-2 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>{signal.kind}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
|
||||
<Badge variant="outline">{signal.state}</Badge>
|
||||
</div>
|
||||
{#snippet signalsContent()}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each signals as signal (signal.id)}
|
||||
<div class="flex flex-col gap-1 border-b pb-1.5 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>{signal.kind}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
|
||||
<Badge variant="outline">{signal.state}</Badge>
|
||||
</div>
|
||||
{#if ['raised', 'acknowledged', 'acting'].includes(signal.state)}
|
||||
<div class="flex justify-end gap-1.5">
|
||||
{#if signal.state === 'raised'}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-6 px-2 text-xs"
|
||||
disabled={actingSignal === signal.id}
|
||||
onclick={() => ackOpenSignal(signal.id)}>Ack</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ['raised', 'acknowledged', 'acting'].includes(signal.state)}
|
||||
<div class="flex justify-end gap-1.5">
|
||||
{#if signal.state === 'raised'}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-6 px-2 text-xs"
|
||||
disabled={actingSignal === signal.id}
|
||||
onclick={() => muteOpenSignal(signal.id)}>Mute 1h</Button
|
||||
onclick={() => ackOpenSignal(signal.id)}>Ack</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
disabled={actingSignal === signal.id}
|
||||
onclick={() => resolveOpenSignal(signal.id)}>Resolve</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-6 px-2 text-xs"
|
||||
disabled={actingSignal === signal.id}
|
||||
onclick={() => muteOpenSignal(signal.id)}>Mute 1h</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
disabled={actingSignal === signal.id}
|
||||
onclick={() => resolveOpenSignal(signal.id)}>Resolve</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet tasksContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each tasks as { task, executionCount } (task.id)}
|
||||
{@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
|
||||
{@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
|
||||
<div class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
{#if onSelectEntity}
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={title} onclick={() => onSelectEntity(task.slug)}>
|
||||
{title}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate" title={title}>{title}</span>
|
||||
{/if}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if outcome}
|
||||
<Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
|
||||
{/if}
|
||||
<Badge variant="outline">{executionCount} action{executionCount === 1 ? '' : 's'}</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No tasks have acted on this entity.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Executions</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each executions as execution (execution.id)}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span>{execution.action}</span>
|
||||
<Badge variant="outline">{execution.status}</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{#snippet knowledgeContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each knowledge as hit (hit.id)}
|
||||
<div class="text-xs">
|
||||
<Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None linked.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Knowledge</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each knowledge as hit (hit.id)}
|
||||
<div class="text-xs">
|
||||
<Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None linked.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{#snippet eventsContent()}
|
||||
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||
{#each events as ev (ev.id)}
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
||||
<span class="truncate">{ev.type}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No events yet.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Recent events</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||
{#each events as ev (ev.id)}
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
||||
<span class="truncate">{ev.type}</span>
|
||||
{#snippet agentActivityContent()}
|
||||
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||
{#each agentActivity as activity (activity.id)}
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
|
||||
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No events yet.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<span class="truncate text-muted-foreground"
|
||||
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No agent activity.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Agent activity</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||
{#each agentActivity as activity (activity.id)}
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
|
||||
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
|
||||
</div>
|
||||
<span class="truncate text-muted-foreground"
|
||||
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
|
||||
>
|
||||
{#snippet auditContent()}
|
||||
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||
{#each auditEntries as entry (entry.id)}
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
|
||||
<Badge variant="outline">{entry.actor_type}</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No agent activity.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No audit entries.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Audit trail</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||
{#each auditEntries as entry (entry.id)}
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
|
||||
<Badge variant="outline">{entry.actor_type}</Badge>
|
||||
</div>
|
||||
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No audit entries.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{@const sections = [
|
||||
...(ownContent ? [{ key: 'content', title: 'Content', count: 1, content: contentContent }] : []),
|
||||
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
|
||||
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
||||
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
|
||||
{ key: 'relations', title: 'Relations', count: relations.length, content: relationsContent },
|
||||
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
|
||||
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
||||
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
||||
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
|
||||
{ key: 'events', title: 'Recent events', count: events.length, content: eventsContent },
|
||||
{ key: 'agentActivity', title: 'Agent activity', count: agentActivity.length, content: agentActivityContent },
|
||||
{ key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent }
|
||||
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
|
||||
|
||||
{#each sections as section (section.key)}
|
||||
<DetailSection title={section.title} count={section.count} defaultOpen={section.count > 0}>
|
||||
{@render section.content()}
|
||||
</DetailSection>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Minimal markdown styling for document/investigation/runbook content —
|
||||
mirrors Chat.svelte's .prose-chat (Svelte scopes styles per-component,
|
||||
so it can't be shared directly). */
|
||||
.prose-chat :global(p) {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.prose-chat :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose-chat :global(ul),
|
||||
.prose-chat :global(ol) {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.prose-chat :global(li) {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.prose-chat :global(code) {
|
||||
background: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.prose-chat :global(pre) {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.625rem 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.prose-chat :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(h1),
|
||||
.prose-chat :global(h2),
|
||||
.prose-chat :global(h3) {
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0 0.375rem;
|
||||
font-size: 1em;
|
||||
}
|
||||
.prose-chat :global(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(th),
|
||||
.prose-chat :global(td) {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
.prose-chat :global(blockquote) {
|
||||
border-left: 3px solid var(--border);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
550
web/src/lib/components/EntityGraph.svelte
Normal file
550
web/src/lib/components/EntityGraph.svelte
Normal file
@@ -0,0 +1,550 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { typeToCategory, type Category } from '$lib/categories'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
|
||||
export interface GraphInfo {
|
||||
allNodeTypes: string[]
|
||||
allRelTypes: string[]
|
||||
relColors: Map<string, string>
|
||||
visibleCount: number
|
||||
truncated: boolean
|
||||
zoomPct: number
|
||||
}
|
||||
|
||||
let {
|
||||
category,
|
||||
selectedSlug = null,
|
||||
onSelect,
|
||||
root = $bindable(''),
|
||||
depth,
|
||||
search,
|
||||
reloadToken,
|
||||
resetToken,
|
||||
activeNodeTypes = $bindable(new Set<string>()),
|
||||
activeRelTypes = $bindable(new Set<string>()),
|
||||
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
}: {
|
||||
category: Category
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string | null) => void
|
||||
root?: string
|
||||
depth: number
|
||||
search: string
|
||||
// Bumped by the parent toolbar to request a data reload / view reset —
|
||||
// these controls live in the shared page toolbar (not squeezed inside
|
||||
// this resizable pane), so they can't call load()/resetView() directly.
|
||||
reloadToken: number
|
||||
resetToken: number
|
||||
activeNodeTypes?: Set<string>
|
||||
activeRelTypes?: Set<string>
|
||||
info?: GraphInfo
|
||||
} = $props()
|
||||
|
||||
interface Node extends Entity {
|
||||
x?: number
|
||||
y?: number
|
||||
vx?: number
|
||||
vy?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
degree: number
|
||||
}
|
||||
interface Link {
|
||||
source: string | Node
|
||||
target: string | Node
|
||||
type: string
|
||||
}
|
||||
|
||||
let graph = $state<GraphView | null>(null)
|
||||
let loading = $state(true)
|
||||
let nodes = $state<Node[]>([])
|
||||
let links = $state<Link[]>([])
|
||||
let sim: Simulation<Node, Link> | null = null
|
||||
|
||||
// type → browsing category, so the graph can be scoped client-side (the
|
||||
// graph endpoint itself has no category/domain param). Value is undefined
|
||||
// for types deliberately excluded from every category (e.g. execution/
|
||||
// check/task — see categories.ts); the key is still present so inCategory
|
||||
// can tell "excluded on purpose" apart from "not in the ontology at all."
|
||||
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
|
||||
|
||||
let hoveredId = $state<string | null>(null)
|
||||
|
||||
// viewport transform: translate(x, y) scale(k)
|
||||
let view = $state({ x: 0, y: 0, k: 1 })
|
||||
let svgEl = $state<SVGSVGElement | null>(null)
|
||||
|
||||
const width = 1200
|
||||
const height = 800
|
||||
|
||||
const healthColor: Record<Health, string> = {
|
||||
healthy: '#3fb950',
|
||||
degraded: '#d29922',
|
||||
down: '#f85149',
|
||||
unknown: '#8b949e'
|
||||
}
|
||||
|
||||
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
|
||||
const relColorByType = $derived.by(() => {
|
||||
const map = new Map<string, string>()
|
||||
const types = Array.from(new Set(links.map((l) => l.type))).sort()
|
||||
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
|
||||
return map
|
||||
})
|
||||
|
||||
function relColor(type: string): string {
|
||||
return relColorByType.get(type) ?? '#30363d'
|
||||
}
|
||||
|
||||
function markerId(type: string): string {
|
||||
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
|
||||
}
|
||||
|
||||
function endpoint(end: string | Node): Node | undefined {
|
||||
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
|
||||
}
|
||||
function endpointId(end: string | Node): string {
|
||||
return typeof end === 'object' ? end.id : end
|
||||
}
|
||||
|
||||
// Node belongs to the active category? Types the ontology never returned
|
||||
// at all fall back to visible (so a missing entry never blanks the
|
||||
// graph); types the ontology returned but categories.ts deliberately
|
||||
// excludes (key present, value undefined) do not.
|
||||
function inCategory(type: string): boolean {
|
||||
if (!typeCategory.has(type)) return true
|
||||
return typeCategory.get(type) === category
|
||||
}
|
||||
|
||||
// Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
|
||||
// default init spreads those via a spiral centered on the ORIGIN — not
|
||||
// (width/2, height/2) — while the x/y centering forces below are
|
||||
// deliberately weak (0.04, so they don't fight the link/collide layout).
|
||||
// Together that meant the cluster could settle noticeably off-origin
|
||||
// instead of centered. Fixed by explicitly fitting the viewport to the
|
||||
// node bounding box once the simulation settles, rather than relying on
|
||||
// the force balance to land on center by itself.
|
||||
function fitToView() {
|
||||
const placed = nodes.filter((n) => n.x != null && n.y != null)
|
||||
if (!placed.length) return
|
||||
const xs = placed.map((n) => n.x as number)
|
||||
const ys = placed.map((n) => n.y as number)
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
const minY = Math.min(...ys)
|
||||
const maxY = Math.max(...ys)
|
||||
const pad = 70
|
||||
const bw = Math.max(maxX - minX, 1)
|
||||
const bh = Math.max(maxY - minY, 1)
|
||||
const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
|
||||
const cx = (minX + maxX) / 2
|
||||
const cy = (minY + maxY) / 2
|
||||
view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
|
||||
}
|
||||
|
||||
// fit=false for passive background reloads (live entity/relationship
|
||||
// events) — those shouldn't yank the view out from under someone
|
||||
// actively panning/zooming. Fresh loads (mount, root/depth change,
|
||||
// reset, re-root) default to fit=true.
|
||||
async function load(fit = true) {
|
||||
loading = true
|
||||
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
||||
loading = false
|
||||
if (!graph) return
|
||||
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
const degree = new Map<string, number>()
|
||||
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
||||
for (const e of graph.edges) {
|
||||
const s = idBySlug.get(e.source) ?? e.source
|
||||
const t = idBySlug.get(e.target) ?? e.target
|
||||
degree.set(s, (degree.get(s) ?? 0) + 1)
|
||||
degree.set(t, (degree.get(t) ?? 0) + 1)
|
||||
}
|
||||
|
||||
nodes = graph.nodes.map((n) => {
|
||||
const prev = byId.get(n.id)
|
||||
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
|
||||
})
|
||||
links = graph.edges.map((e) => ({
|
||||
source: idBySlug.get(e.source) ?? e.source,
|
||||
target: idBySlug.get(e.target) ?? e.target,
|
||||
type: e.type
|
||||
}))
|
||||
|
||||
// Default the node/edge-type toggles to the types present in the active category.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
||||
activeRelTypes = new Set(links.map((l) => l.type))
|
||||
|
||||
sim?.stop()
|
||||
sim = forceSimulation(nodes)
|
||||
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
|
||||
.force('charge', forceManyBody().strength(-240).distanceMax(400))
|
||||
.force('center', forceCenter(width / 2, height / 2))
|
||||
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
|
||||
.force('x', forceX(width / 2).strength(0.04))
|
||||
.force('y', forceY(height / 2).strength(0.04))
|
||||
.velocityDecay(0.32)
|
||||
.alphaDecay(0.035)
|
||||
.on('tick', () => {
|
||||
nodes = [...nodes]
|
||||
})
|
||||
.on('end', () => {
|
||||
if (fit) fitToView()
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchEntityTypes().then((types) => {
|
||||
typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
|
||||
// Re-derive the active node types now that category membership is known.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
||||
})
|
||||
load()
|
||||
const unsubscribe = subscribeEvents()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
sim?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
// When the category perspective changes, reset the node-type toggles to it.
|
||||
$effect(() => {
|
||||
category
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const ev = $liveEvents[0]
|
||||
if (!ev) return
|
||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
||||
load(false)
|
||||
}
|
||||
})
|
||||
|
||||
// Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
|
||||
// typing freely doesn't refetch, only a committed change (Enter/blur in the
|
||||
// parent's inputs, or the Reset button) bumps the token.
|
||||
let lastReloadToken = $state(0)
|
||||
$effect(() => {
|
||||
if (reloadToken !== lastReloadToken) {
|
||||
lastReloadToken = reloadToken
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
let lastResetToken = $state(0)
|
||||
$effect(() => {
|
||||
if (resetToken !== lastResetToken) {
|
||||
lastResetToken = resetToken
|
||||
view = { x: 0, y: 0, k: 1 }
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
function selectNode(node: Node) {
|
||||
onSelect(node.slug)
|
||||
}
|
||||
|
||||
function rerootTo(node: Node) {
|
||||
root = node.slug
|
||||
load()
|
||||
}
|
||||
|
||||
function nodeColor(node: Node): string {
|
||||
const h = graph?.health?.[node.id]
|
||||
return h ? healthColor[h] : '#58a6ff'
|
||||
}
|
||||
|
||||
function nodeRadius(node: Node): number {
|
||||
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
|
||||
}
|
||||
|
||||
// Only offer node-type toggles that live in the active category.
|
||||
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort())
|
||||
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
||||
|
||||
// Publish status/legend info up to the parent toolbar.
|
||||
$effect(() => {
|
||||
info = {
|
||||
allNodeTypes,
|
||||
allRelTypes,
|
||||
relColors: relColorByType,
|
||||
visibleCount: visibleNodeIds.size,
|
||||
truncated: !!graph?.truncated,
|
||||
zoomPct: Math.round(view.k * 100)
|
||||
}
|
||||
})
|
||||
|
||||
const matchedIds = $derived.by(() => {
|
||||
if (!search.trim()) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
|
||||
})
|
||||
|
||||
// Focus = in the active category AND its node-type toggle is on — these
|
||||
// are what the category tab is "about."
|
||||
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
|
||||
// Real infra relationships mostly cross category lines (a service sits on
|
||||
// a network, uses storage, runs on an lxc — different categories under
|
||||
// this taxonomy). Hard-hiding any edge whose other end isn't in-category
|
||||
// left focus nodes looking like disconnected dots. Rooted views (the user
|
||||
// is exploring out from one entity) pull in 1-hop neighbors of any
|
||||
// category, dimmed, so the edges — and what they connect to — stay
|
||||
// visible. Unscoped "browse the whole category" views (no root) skip
|
||||
// this: with ~50 focus nodes that touch nearly everything, 1-hop
|
||||
// expansion floods in most of the graph (measured: 417 of 479 total
|
||||
// entities for an unrooted Fleet view) — worse than the isolated-dot
|
||||
// problem it was meant to fix. There, same-category-only edges stay.
|
||||
const neighborNodeIds = $derived.by(() => {
|
||||
const neighbors = new Set<string>()
|
||||
if (!root.trim()) return neighbors
|
||||
for (const l of links) {
|
||||
if (!activeRelTypes.has(l.type)) continue
|
||||
const s = endpointId(l.source)
|
||||
const t = endpointId(l.target)
|
||||
if (focusNodeIds.has(s) && !focusNodeIds.has(t)) neighbors.add(t)
|
||||
else if (focusNodeIds.has(t) && !focusNodeIds.has(s)) neighbors.add(s)
|
||||
}
|
||||
return neighbors
|
||||
})
|
||||
|
||||
const visibleNodeIds = $derived(new Set([...focusNodeIds, ...neighborNodeIds]))
|
||||
|
||||
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
|
||||
|
||||
const adjacency = $derived.by(() => {
|
||||
const adj = new Map<string, Set<string>>()
|
||||
for (const l of links) {
|
||||
const s = endpointId(l.source)
|
||||
const t = endpointId(l.target)
|
||||
if (!adj.has(s)) adj.set(s, new Set())
|
||||
if (!adj.has(t)) adj.set(t, new Set())
|
||||
adj.get(s)!.add(t)
|
||||
adj.get(t)!.add(s)
|
||||
}
|
||||
return adj
|
||||
})
|
||||
|
||||
const focusIds = $derived.by(() => {
|
||||
const focus = hoveredId ?? selectedId
|
||||
if (!focus) return null
|
||||
const set = new Set<string>([focus])
|
||||
for (const n of adjacency.get(focus) ?? []) set.add(n)
|
||||
return set
|
||||
})
|
||||
|
||||
function nodeOpacity(node: Node): number {
|
||||
const base = focusNodeIds.has(node.id) ? 1 : 0.4
|
||||
if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
|
||||
if (focusIds !== null) return focusIds.has(node.id) ? 1 : Math.min(base, 0.15)
|
||||
return base
|
||||
}
|
||||
|
||||
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
|
||||
const s = endpointId(link.source)
|
||||
const t = endpointId(link.target)
|
||||
const focus = hoveredId ?? selectedId
|
||||
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
|
||||
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
|
||||
return { opacity: 0.45, emphasized: false }
|
||||
}
|
||||
|
||||
// ─── pan / zoom / drag ───────────────────────────────────────────────
|
||||
|
||||
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const rect = svgEl!.getBoundingClientRect()
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * width,
|
||||
y: ((clientY - rect.top) / rect.height) * height
|
||||
}
|
||||
}
|
||||
|
||||
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const p = toViewBox(clientX, clientY)
|
||||
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
|
||||
const k = Math.min(6, Math.max(0.25, view.k * factor))
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
const wx = (p.x - view.x) / view.k
|
||||
const wy = (p.y - view.y) / view.k
|
||||
view = { k, x: p.x - wx * k, y: p.y - wy * k }
|
||||
}
|
||||
|
||||
let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null)
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
function onBackgroundPointerDown(e: PointerEvent) {
|
||||
if (dragState) return
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false }
|
||||
}
|
||||
|
||||
function onNodePointerDown(e: PointerEvent, node: Node) {
|
||||
e.stopPropagation()
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
dragState = { node, moved: false }
|
||||
sim?.alphaTarget(0.25).restart()
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (dragState) {
|
||||
const w = toWorld(e.clientX, e.clientY)
|
||||
dragState.node.fx = w.x
|
||||
dragState.node.fy = w.y
|
||||
dragState.moved = true
|
||||
return
|
||||
}
|
||||
if (panState) {
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
const dx = p.x - panState.startX
|
||||
const dy = p.y - panState.startY
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true
|
||||
view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy }
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (dragState) {
|
||||
const { node, moved } = dragState
|
||||
node.fx = null
|
||||
node.fy = null
|
||||
sim?.alphaTarget(0)
|
||||
dragState = null
|
||||
if (!moved) selectNode(node)
|
||||
return
|
||||
}
|
||||
if (panState && !panState.moved) {
|
||||
// Plain click on empty background (not a drag-pan) — clear selection.
|
||||
onSelect(null)
|
||||
}
|
||||
panState = null
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading && !nodes.length}
|
||||
<Skeleton class="h-full min-h-0" />
|
||||
{:else}
|
||||
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border bg-[radial-gradient(ellipse_at_center,rgba(88,166,255,0.04),transparent_70%)]">
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
viewBox="0 0 {width} {height}"
|
||||
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
|
||||
role="application"
|
||||
aria-label="Entity graph"
|
||||
onwheel={onWheel}
|
||||
onpointerdown={onBackgroundPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<defs>
|
||||
{#each allRelTypes as type}
|
||||
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
|
||||
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
|
||||
</marker>
|
||||
{/each}
|
||||
</defs>
|
||||
<g transform="translate({view.x},{view.y}) scale({view.k})">
|
||||
<g>
|
||||
{#each links as link}
|
||||
{@const s = endpoint(link.source)}
|
||||
{@const t = endpoint(link.target)}
|
||||
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)}
|
||||
{@const vs = linkVisualState(link)}
|
||||
{@const dx = t.x - s.x}
|
||||
{@const dy = t.y - s.y}
|
||||
{@const len = Math.max(Math.hypot(dx, dy), 1)}
|
||||
{@const tr = nodeRadius(t) + 3}
|
||||
{@const ex = t.x - (dx / len) * tr}
|
||||
{@const ey = t.y - (dy / len) * tr}
|
||||
<line
|
||||
x1={s.x}
|
||||
y1={s.y}
|
||||
x2={ex}
|
||||
y2={ey}
|
||||
stroke={relColor(link.type)}
|
||||
stroke-width={vs.emphasized ? 2 : 1.2}
|
||||
opacity={vs.opacity}
|
||||
marker-end="url(#{markerId(link.type)})"
|
||||
>
|
||||
<title>{link.type}</title>
|
||||
</line>
|
||||
{#if vs.emphasized && view.k >= 0.7}
|
||||
<text
|
||||
x={(s.x + ex) / 2}
|
||||
y={(s.y + ey) / 2 - 4}
|
||||
text-anchor="middle"
|
||||
font-size={10 / view.k}
|
||||
fill={relColor(link.type)}
|
||||
opacity="0.95"
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-width={3 / view.k}
|
||||
>
|
||||
{link.type}
|
||||
</text>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
<g>
|
||||
{#each nodes as node (node.id)}
|
||||
{#if node.x != null && node.y != null && visibleNodeIds.has(node.id)}
|
||||
{@const r = nodeRadius(node)}
|
||||
{@const op = nodeOpacity(node)}
|
||||
{@const isFocus = hoveredId === node.id || selectedId === node.id}
|
||||
{@const isMatch = matchedIds !== null && matchedIds.has(node.id)}
|
||||
<g
|
||||
transform="translate({node.x},{node.y})"
|
||||
opacity={op}
|
||||
class="cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => onNodePointerDown(e, node)}
|
||||
onpointerenter={() => (hoveredId = node.id)}
|
||||
onpointerleave={() => (hoveredId = null)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
|
||||
ondblclick={() => rerootTo(node)}
|
||||
>
|
||||
{#if isFocus || isMatch}
|
||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||
{/if}
|
||||
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? '#e6edf3' : '#0d1117'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
|
||||
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
|
||||
<text
|
||||
y={r + 12}
|
||||
text-anchor="middle"
|
||||
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
|
||||
fill={isFocus ? '#e6edf3' : '#8b949e'}
|
||||
paint-order="stroke"
|
||||
stroke="#0d1117"
|
||||
stroke-width={3 / view.k}
|
||||
class="pointer-events-none select-none"
|
||||
>
|
||||
{node.slug}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
|
||||
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -3,16 +3,26 @@
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
|
||||
let { slug, open = $bindable(false) }: { slug: string | null; open?: boolean } = $props()
|
||||
|
||||
// Lets a relation click inside the sheet drill into that entity in place,
|
||||
// without closing/reopening. Resets to the externally-requested slug
|
||||
// whenever the caller opens the sheet on a different entity.
|
||||
let currentSlug = $state(slug)
|
||||
$effect(() => {
|
||||
currentSlug = slug
|
||||
})
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Content side="right" class="w-full p-0 sm:max-w-2xl">
|
||||
<Sheet.Header class="sr-only">
|
||||
<Sheet.Title>{slug ?? 'Entity detail'}</Sheet.Title>
|
||||
<Sheet.Title>{currentSlug ?? 'Entity detail'}</Sheet.Title>
|
||||
<Sheet.Description>Entity detail panel</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
{#if slug}
|
||||
<EntityDetailContent {slug} />
|
||||
{#if currentSlug}
|
||||
{#key currentSlug}
|
||||
<EntityDetailContent slug={currentSlug} onSelectEntity={(s) => (currentSlug = s)} />
|
||||
{/key}
|
||||
{/if}
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
178
web/src/lib/components/EntityTable.svelte
Normal file
178
web/src/lib/components/EntityTable.svelte
Normal file
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import type { Entity, EntityHealth } from '$lib/api'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import * as Table from '$lib/components/ui/table'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
|
||||
|
||||
let {
|
||||
entities,
|
||||
loading,
|
||||
selectedSlug = null,
|
||||
onSelect
|
||||
}: {
|
||||
entities: Entity[]
|
||||
loading: boolean
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string) => void
|
||||
} = $props()
|
||||
|
||||
type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health'
|
||||
let sortKey = $state<SortKey>('slug')
|
||||
let sortDir = $state<'asc' | 'desc'>('asc')
|
||||
|
||||
function sortBy(key: SortKey) {
|
||||
if (sortKey === key) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortKey = key
|
||||
sortDir = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
const healthRank: Record<EntityHealth, number> = { down: 0, degraded: 1, stale: 2, unknown: 3, healthy: 4 }
|
||||
|
||||
function sortValue(entity: Entity, key: SortKey): string | number {
|
||||
if (key === 'health') return entity.health ? healthRank[entity.health] : -1
|
||||
return (entity[key] ?? '').toString().toLowerCase()
|
||||
}
|
||||
|
||||
const sortedEntities = $derived.by(() => {
|
||||
const sorted = [...entities].sort((a, b) => {
|
||||
const av = sortValue(a, sortKey)
|
||||
const bv = sortValue(b, sortKey)
|
||||
if (av < bv) return -1
|
||||
if (av > bv) return 1
|
||||
return 0
|
||||
})
|
||||
if (sortDir === 'desc') sorted.reverse()
|
||||
return sorted
|
||||
})
|
||||
|
||||
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
|
||||
if (!state) return 'outline'
|
||||
if (state === 'active' || state === 'healthy') return 'default'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
const healthDot: Record<EntityHealth, string> = {
|
||||
healthy: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
stale: 'bg-warning/50',
|
||||
unknown: 'bg-muted-foreground/40'
|
||||
}
|
||||
|
||||
function healthTitle(entity: Entity): string {
|
||||
if (!entity.health) return 'not monitored'
|
||||
if (entity.health === 'stale') return `stale — last checked ${relativeTime(entity.last_check_at)}`
|
||||
return `${entity.health} — checked ${relativeTime(entity.last_check_at)}`
|
||||
}
|
||||
|
||||
// Widths vary per row so the skeleton reads as text, not a stack of identical bars.
|
||||
const skeletonSlugWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
|
||||
const skeletonNameWidths = ['w-32', 'w-40', 'w-24', 'w-36', 'w-28', 'w-40', 'w-24', 'w-32']
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="h-full min-h-0 overflow-hidden rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Slug</Table.Head>
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>State</Table.Head>
|
||||
<Table.Head>Health</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each skeletonSlugWidths as slugWidth, i}
|
||||
<Table.Row class="hover:bg-transparent">
|
||||
<Table.Cell><Skeleton class="h-4 {slugWidth}" /></Table.Cell>
|
||||
<Table.Cell><Skeleton class="h-5 w-16 rounded-full" /></Table.Cell>
|
||||
<Table.Cell><Skeleton class="h-4 {skeletonNameWidths[i]}" /></Table.Cell>
|
||||
<Table.Cell><Skeleton class="h-5 w-14 rounded-full" /></Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Skeleton class="size-2 shrink-0 rounded-full" />
|
||||
<Skeleton class="h-4 w-12" />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{:else}
|
||||
{#snippet sortHead(key: SortKey, label: string)}
|
||||
<Table.Head>
|
||||
<button type="button" class="flex items-center gap-1 hover:text-foreground" onclick={() => sortBy(key)}>
|
||||
{label}
|
||||
{#if sortKey === key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 text-muted-foreground/50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/snippet}
|
||||
<div class="h-full min-h-0 overflow-auto rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{@render sortHead('slug', 'Slug')}
|
||||
{@render sortHead('type', 'Type')}
|
||||
{@render sortHead('name', 'Name')}
|
||||
{@render sortHead('state', 'State')}
|
||||
{@render sortHead('health', 'Health')}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each sortedEntities as entity (entity.id)}
|
||||
<Table.Row
|
||||
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onclick={() => onSelect(entity.slug)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }}
|
||||
>
|
||||
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
|
||||
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||
<Table.Cell>{entity.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if entity.state}
|
||||
<Badge variant={stateVariant(entity.state)}>{entity.state}</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
|
||||
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
|
||||
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-center text-muted-foreground"
|
||||
>No entities in this layer match the filter.</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
233
web/src/lib/components/GraphBackground.svelte
Normal file
233
web/src/lib/components/GraphBackground.svelte
Normal file
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, type Health } from '$lib/api'
|
||||
import { mode } from 'mode-watcher'
|
||||
|
||||
// Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
|
||||
// host places this behind the page with pointer-events:none, so it never
|
||||
// steals clicks. The "alive" feeling comes entirely from the camera (slow
|
||||
// autonomous drift + mouse parallax + per-node depth), NOT from a live force
|
||||
// sim — we warm the layout up once, freeze it, then just pan a static field.
|
||||
|
||||
interface SimNode {
|
||||
id: string
|
||||
slug: string
|
||||
degree: number
|
||||
z: number // depth in [0,1] for parallax
|
||||
x?: number
|
||||
y?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
}
|
||||
interface SimLink {
|
||||
source: string | SimNode
|
||||
target: string | SimNode
|
||||
}
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null)
|
||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||
|
||||
let nodes: SimNode[] = []
|
||||
let links: SimLink[] = []
|
||||
let health: Record<string, Health> = {}
|
||||
|
||||
// World bounds the layout is centered in; camera pans within.
|
||||
const WORLD = 1400
|
||||
const MAX_NODES = 260
|
||||
|
||||
const healthColor: Record<Health, string> = {
|
||||
healthy: '#3fb950',
|
||||
degraded: '#d29922',
|
||||
down: '#f85149',
|
||||
unknown: '#8b949e'
|
||||
}
|
||||
|
||||
function nodeRadius(n: SimNode): number {
|
||||
return 3 + Math.min(Math.sqrt(n.degree) * 1.4, 7)
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
const graph = await fetchGraph({ depth: 3, includeStatus: true })
|
||||
if (!graph) return
|
||||
health = graph.health ?? {}
|
||||
|
||||
// degree by id, edges reference slugs
|
||||
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
||||
const degree = new Map<string, number>()
|
||||
for (const e of graph.edges) {
|
||||
const s = idBySlug.get(e.source) ?? e.source
|
||||
const t = idBySlug.get(e.target) ?? e.target
|
||||
degree.set(s, (degree.get(s) ?? 0) + 1)
|
||||
degree.set(t, (degree.get(t) ?? 0) + 1)
|
||||
}
|
||||
|
||||
let all: SimNode[] = graph.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
slug: n.slug,
|
||||
degree: degree.get(n.id) ?? 0,
|
||||
z: Math.random()
|
||||
}))
|
||||
// Cap to the most-connected nodes so large graphs stay cheap.
|
||||
if (all.length > MAX_NODES) {
|
||||
all = [...all].sort((a, b) => b.degree - a.degree).slice(0, MAX_NODES)
|
||||
}
|
||||
const keep = new Set(all.map((n) => n.id))
|
||||
nodes = all
|
||||
links = graph.edges
|
||||
.map((e) => ({ source: idBySlug.get(e.source) ?? e.source, target: idBySlug.get(e.target) ?? e.target }))
|
||||
.filter((l) => keep.has(l.source as string) && keep.has(l.target as string))
|
||||
|
||||
warmUpLayout()
|
||||
}
|
||||
|
||||
// Run the sim to a settled state without rendering each tick, then freeze.
|
||||
function warmUpLayout() {
|
||||
const sim: Simulation<SimNode, SimLink> = forceSimulation(nodes)
|
||||
.force('link', forceLink<SimNode, SimLink>(links).id((n) => n.id).distance(60).strength(0.5))
|
||||
.force('charge', forceManyBody().strength(-140).distanceMax(360))
|
||||
.force('center', forceCenter(0, 0))
|
||||
.force('collide', forceCollide<SimNode>((n) => nodeRadius(n) + 6))
|
||||
.stop()
|
||||
const ticks = Math.min(400, Math.max(120, nodes.length * 2))
|
||||
for (let i = 0; i < ticks; i++) sim.tick()
|
||||
sim.stop()
|
||||
}
|
||||
|
||||
// ─── camera + render loop ───────────────────────────────────────────────
|
||||
|
||||
let cam = { x: 0, y: 0 } // eased mouse-parallax offset
|
||||
let targetCam = { x: 0, y: 0 }
|
||||
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
||||
let dpr = 1
|
||||
let w = 0
|
||||
let h = 0
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!host) return
|
||||
const rect = host.getBoundingClientRect()
|
||||
const nx = (e.clientX - rect.left) / rect.width - 0.5 // -0.5..0.5
|
||||
const ny = (e.clientY - rect.top) / rect.height - 0.5
|
||||
targetCam = { x: -nx * 90, y: -ny * 90 } // small parallax nudge
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!host || !canvas) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
w = host.clientWidth
|
||||
h = host.clientHeight
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
}
|
||||
|
||||
function colorForNode(n: SimNode): string {
|
||||
return healthColor[health[n.id] ?? 'unknown']
|
||||
}
|
||||
|
||||
// Driven by setTimeout rather than requestAnimationFrame: some embedding
|
||||
// contexts (iframed previews, backgrounded-but-visible panes) report
|
||||
// document.hidden = true and browsers fully suspend rAF callbacks there,
|
||||
// which would freeze this canvas forever. setTimeout keeps ticking
|
||||
// regardless, and ~30fps is plenty for a slow ambient drift.
|
||||
function draw(t: number) {
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// ease parallax toward target
|
||||
cam.x += (targetCam.x - cam.x) * 0.05
|
||||
cam.y += (targetCam.y - cam.y) * 0.05
|
||||
|
||||
// autonomous drift (Lissajous pan + breathing zoom)
|
||||
const ts = t / 1000
|
||||
const driftX = Math.sin(ts * 0.05) * 70 + Math.sin(ts * 0.017) * 40
|
||||
const driftY = Math.cos(ts * 0.043) * 60 + Math.sin(ts * 0.023) * 30
|
||||
const zoom = 0.82 + Math.sin(ts * 0.03) * 0.03
|
||||
|
||||
const dark = mode.current !== 'light'
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
|
||||
// project a world point to screen, applying per-depth parallax
|
||||
function project(px: number, py: number, z: number) {
|
||||
const par = 0.5 + z // nearer nodes (higher z) move more
|
||||
const ox = (driftX + cam.x) * par
|
||||
const oy = (driftY + cam.y) * par
|
||||
return { x: cx + (px + ox) * zoom, y: cy + (py + oy) * zoom }
|
||||
}
|
||||
|
||||
// edges
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = dark ? 'rgba(140,175,230,0.28)' : 'rgba(60,90,140,0.22)'
|
||||
ctx.beginPath()
|
||||
for (const l of links) {
|
||||
const s = l.source as SimNode
|
||||
const tg = l.target as SimNode
|
||||
if (s.x == null || tg.x == null) continue
|
||||
const z = (s.z + tg.z) / 2
|
||||
const a = project(s.x, s.y!, z)
|
||||
const b = project(tg.x, tg.y!, z)
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(b.x, b.y)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// nodes (glow via radial gradient, cheap enough at this count)
|
||||
for (const n of nodes) {
|
||||
if (n.x == null || n.y == null) continue
|
||||
const p = project(n.x, n.y, n.z)
|
||||
const r = nodeRadius(n) * zoom * (0.7 + n.z * 0.6)
|
||||
const col = colorForNode(n)
|
||||
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 3.2)
|
||||
glow.addColorStop(0, hexA(col, dark ? 0.45 : 0.32))
|
||||
glow.addColorStop(1, hexA(col, 0))
|
||||
ctx.fillStyle = glow
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r * 3.2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = hexA(col, dark ? 0.7 : 0.55)
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// legibility scrim: dim only the center band where the UI sits, taper to
|
||||
// ~nothing at the edges so the graph (and its connections) stay visible
|
||||
// in the margins instead of being crushed everywhere equally.
|
||||
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
||||
const base = dark ? '13,17,23' : '255,255,255'
|
||||
scrim.addColorStop(0, `rgba(${base},0.68)`)
|
||||
scrim.addColorStop(0.45, `rgba(${base},0.32)`)
|
||||
scrim.addColorStop(1, `rgba(${base},0.02)`)
|
||||
ctx.fillStyle = scrim
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
}
|
||||
|
||||
// "#rrggbb" + alpha -> rgba()
|
||||
function hexA(hex: string, a: number): string {
|
||||
const n = parseInt(hex.slice(1), 16)
|
||||
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadGraph()
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
if (host) ro.observe(host)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
ro.disconnect()
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div bind:this={host} class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<canvas bind:this={canvas} class="h-full w-full"></canvas>
|
||||
</div>
|
||||
64
web/src/lib/components/MultiSelectFilter.svelte
Normal file
64
web/src/lib/components/MultiSelectFilter.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
|
||||
let {
|
||||
label,
|
||||
options,
|
||||
selected = $bindable(),
|
||||
colorFor
|
||||
}: {
|
||||
label: string
|
||||
options: string[]
|
||||
selected: Set<string>
|
||||
colorFor?: (option: string) => string
|
||||
} = $props()
|
||||
|
||||
function toggle(opt: string) {
|
||||
const next = new Set(selected)
|
||||
if (next.has(opt)) next.delete(opt)
|
||||
else next.add(opt)
|
||||
selected = next
|
||||
}
|
||||
|
||||
const allSelected = $derived(options.length > 0 && options.every((o) => selected.has(o)))
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" size="sm" class="h-8 gap-1.5">
|
||||
{label}
|
||||
<span class="text-muted-foreground">{selected.size}/{options.length}</span>
|
||||
<ChevronDownIcon class="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="max-h-80 w-56 overflow-y-auto" align="start">
|
||||
<DropdownMenu.Item
|
||||
closeOnSelect={false}
|
||||
onSelect={() => { selected = allSelected ? new Set() : new Set(options) }}
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{#each options as opt}
|
||||
<DropdownMenu.CheckboxItem
|
||||
closeOnSelect={false}
|
||||
checked={selected.has(opt)}
|
||||
onCheckedChange={() => toggle(opt)}
|
||||
class="text-xs"
|
||||
>
|
||||
{#if colorFor}
|
||||
<span class="size-2 shrink-0 rounded-full" style="background: {colorFor(opt)}"></span>
|
||||
{/if}
|
||||
{opt}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
{#if options.length === 0}
|
||||
<p class="px-2 py-1.5 text-xs text-muted-foreground">No types loaded yet.</p>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -7,11 +7,14 @@
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
|
||||
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
|
||||
let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
|
||||
|
||||
let open = $state(false)
|
||||
let wasActive = $state(active)
|
||||
|
||||
const bodyTools = $derived(unmatched ?? tools)
|
||||
const inlineCount = $derived(tools.length - bodyTools.length)
|
||||
|
||||
$effect(() => {
|
||||
if (active && !wasActive) {
|
||||
open = true
|
||||
@@ -22,18 +25,18 @@
|
||||
wasActive = active
|
||||
})
|
||||
|
||||
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
|
||||
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
|
||||
const names = $derived(tools.map((t) => t.name).join(', '))
|
||||
const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
|
||||
const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
|
||||
const names = $derived(bodyTools.map((t) => t.name).join(', '))
|
||||
|
||||
const runningTool = $derived(
|
||||
active ? tools.find((t) => t.type === 'tool_use') : undefined
|
||||
active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
|
||||
)
|
||||
|
||||
const ariaLabel = $derived(
|
||||
doneCount === tools.length
|
||||
? `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} completed`
|
||||
: `${doneCount}/${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} done`
|
||||
doneCount === bodyTools.length
|
||||
? `${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} completed`
|
||||
: `${doneCount}/${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} done`
|
||||
)
|
||||
|
||||
function toolSummary(args: unknown): string {
|
||||
@@ -45,19 +48,19 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if tools.length}
|
||||
{#if bodyTools.length}
|
||||
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50">
|
||||
{#if active && doneCount < tools.length}
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
|
||||
{#if active && doneCount < bodyTools.length}
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
{:else if hasError}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
{:else}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
{/if}
|
||||
|
||||
{#if active && doneCount < tools.length}
|
||||
<span class="font-medium">{doneCount}/{tools.length}</span>
|
||||
{#if active && doneCount < bodyTools.length}
|
||||
<span class="font-medium">{doneCount}/{bodyTools.length}</span>
|
||||
{#if runningTool}
|
||||
<span class="max-w-48 truncate font-mono text-muted-foreground">
|
||||
{runningTool.name}
|
||||
@@ -67,7 +70,10 @@
|
||||
<span class="animate-pulse text-muted-foreground">working…</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
|
||||
<span class="font-medium">{bodyTools.length} tool{bodyTools.length === 1 ? '' : 's'}</span>
|
||||
{#if inlineCount > 0}
|
||||
<span class="text-muted-foreground">· {inlineCount} card{inlineCount === 1 ? '' : 's'} shown</span>
|
||||
{/if}
|
||||
<span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span>
|
||||
{/if}
|
||||
|
||||
@@ -78,16 +84,16 @@
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2">
|
||||
<div class="flex flex-col divide-y border-t">
|
||||
{#each tools as tool (tool.id)}
|
||||
<div class="flex flex-col divide-y border-t" role="list" aria-label={ariaLabel}>
|
||||
{#each bodyTools as tool (tool.id)}
|
||||
<div class="p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if tool.type === 'tool_result' && tool.error}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
{:else if tool.type === 'tool_result'}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
{:else}
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="font-mono font-medium">{tool.name}</span>
|
||||
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
|
||||
|
||||
106
web/src/lib/config.ts
Normal file
106
web/src/lib/config.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// Runtime configuration for the SPA — server URL + auth token. Every fetch
|
||||
// call goes through fetchWithAuth/apiBase (used by api.ts) so the SPA works
|
||||
// identically whether it's served same-origin (browser prod, Vite dev proxy)
|
||||
// or cross-origin (Wails webview, remote access). See
|
||||
// plans/2026-07-12-wails-desktop-app.md 0.2.
|
||||
|
||||
import { getToken, isOIDCConfigured } from './oidc'
|
||||
|
||||
export interface OikosConfig {
|
||||
apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin
|
||||
token?: string // bearer token for auth
|
||||
isDesktop?: boolean // true when running inside the Wails desktop app
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OIKOS_CONFIG__?: OikosConfig
|
||||
}
|
||||
}
|
||||
|
||||
let cfg: OikosConfig | undefined
|
||||
|
||||
export function initConfig(override?: OikosConfig) {
|
||||
cfg = override ?? window.__OIKOS_CONFIG__
|
||||
if (cfg?.token) {
|
||||
localStorage.setItem('oikos_token', cfg.token)
|
||||
if (cfg.apiUrl) localStorage.setItem('oikos_api_url', cfg.apiUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): OikosConfig {
|
||||
if (!cfg) {
|
||||
const token = localStorage.getItem('oikos_token')
|
||||
const apiUrl = localStorage.getItem('oikos_api_url')
|
||||
if (token || apiUrl) {
|
||||
cfg = { apiUrl: apiUrl ?? '', token: token ?? undefined }
|
||||
}
|
||||
}
|
||||
return cfg ?? { apiUrl: '' }
|
||||
}
|
||||
|
||||
export function setConfig(next: OikosConfig) {
|
||||
cfg = next
|
||||
if (next.token) localStorage.setItem('oikos_token', next.token)
|
||||
else localStorage.removeItem('oikos_token')
|
||||
if (next.apiUrl) localStorage.setItem('oikos_api_url', next.apiUrl)
|
||||
else localStorage.removeItem('oikos_api_url')
|
||||
}
|
||||
|
||||
export function clearConfig() {
|
||||
cfg = { apiUrl: '' }
|
||||
localStorage.removeItem('oikos_token')
|
||||
localStorage.removeItem('oikos_api_url')
|
||||
}
|
||||
|
||||
export function isConfigured(): boolean {
|
||||
return !!getConfig().token || isOIDCConfigured()
|
||||
}
|
||||
|
||||
// Relative paths are used in dev (Vite proxy) and when the SPA shares an
|
||||
// origin with the API server (Caddy reverse proxy). Absolute paths are used
|
||||
// when the API server is on a different origin (Wails webview, remote access).
|
||||
export function apiBase(path: string): string {
|
||||
const c = getConfig()
|
||||
if (!c.apiUrl) return path // relative — relies on same-origin or Vite proxy
|
||||
return `${c.apiUrl}${path}`
|
||||
}
|
||||
|
||||
// Resolves the auth token for a request: OIDC takes precedence, then static.
|
||||
function resolveAuthHeader(): string | null {
|
||||
const oidcToken = getToken()
|
||||
if (oidcToken) return `Bearer ${oidcToken}`
|
||||
const c = getConfig()
|
||||
if (c.token) return `Bearer ${c.token}`
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- Auth fetch wrapper ----
|
||||
// Prepends the API base URL (absolute when configured, relative when unset
|
||||
// for the Vite dev proxy / same-origin prod) and adds the Authorization
|
||||
// header. Used by every fetch call in api.ts.
|
||||
export async function fetchWithAuth(path: string, opts?: RequestInit): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(opts?.headers as Record<string, string> ?? {})
|
||||
}
|
||||
const authH = resolveAuthHeader()
|
||||
if (authH) {
|
||||
headers['Authorization'] = authH
|
||||
}
|
||||
|
||||
return fetch(apiBase(path), { ...opts, headers })
|
||||
}
|
||||
|
||||
// SSE path builder — EventSource doesn't take headers, so pass the token as
|
||||
// a query parameter (the SSE handler's combinedAuth checks it alongside the
|
||||
// Authorization header, only for this route).
|
||||
export function sseUrl(path: string): string {
|
||||
const url = apiBase(path)
|
||||
const c = getConfig()
|
||||
const oidcToken = getToken()
|
||||
const token = oidcToken ?? c.token
|
||||
if (!token) return url
|
||||
const sep = url.includes('?') ? '&' : '?'
|
||||
return `${url}${sep}token=${encodeURIComponent(token)}`
|
||||
}
|
||||
272
web/src/lib/oidc.ts
Normal file
272
web/src/lib/oidc.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { apiBase } from './config'
|
||||
|
||||
interface OIDCConfig {
|
||||
issuer: string
|
||||
client_id: string
|
||||
authorization_endpoint: string
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in?: number
|
||||
refresh_token?: string
|
||||
id_token?: string
|
||||
}
|
||||
|
||||
interface OIDCState {
|
||||
config: OIDCConfig | null
|
||||
accessToken: string | null
|
||||
refreshToken: string | null
|
||||
user: string | null
|
||||
refreshing: Promise<string | null> | null
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'oidc_access_token'
|
||||
const REFRESH_KEY = 'oidc_refresh_token'
|
||||
const USER_KEY = 'oidc_user'
|
||||
const PKCE_KEY = 'oidc_pkce_verifier'
|
||||
const STATE_KEY = 'oidc_state'
|
||||
|
||||
let state: OIDCState = {
|
||||
config: null,
|
||||
accessToken: sessionStorage.getItem(SESSION_KEY),
|
||||
refreshToken: localStorage.getItem(REFRESH_KEY),
|
||||
user: localStorage.getItem(USER_KEY),
|
||||
refreshing: null
|
||||
}
|
||||
|
||||
async function fetchConfig(): Promise<OIDCConfig | null> {
|
||||
try {
|
||||
const resp = await fetch(apiBase('/api/v1/auth/oidc-config'))
|
||||
if (!resp.ok) return null
|
||||
const cfg: OIDCConfig = await resp.json()
|
||||
state.config = cfg
|
||||
return cfg
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function base64URLEncode(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function generateRandom(len: number): string {
|
||||
const arr = new Uint8Array(len)
|
||||
crypto.getRandomValues(arr)
|
||||
return base64URLEncode(arr)
|
||||
}
|
||||
|
||||
async function sha256(plain: string): Promise<ArrayBuffer> {
|
||||
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain))
|
||||
}
|
||||
|
||||
export async function startLogin(): Promise<void> {
|
||||
const cfg = state.config ?? await fetchConfig()
|
||||
if (!cfg) throw new Error('OIDC not configured on server')
|
||||
|
||||
const codeVerifier = generateRandom(64)
|
||||
const challengeBuf = await sha256(codeVerifier)
|
||||
const codeChallenge = base64URLEncode(challengeBuf)
|
||||
const oidcState = generateRandom(32)
|
||||
|
||||
sessionStorage.setItem(PKCE_KEY, codeVerifier)
|
||||
sessionStorage.setItem(STATE_KEY, oidcState)
|
||||
|
||||
const redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: cfg.client_id,
|
||||
redirect_uri: redirectURI,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state: oidcState,
|
||||
scope: 'openid profile email'
|
||||
})
|
||||
|
||||
location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}`
|
||||
}
|
||||
|
||||
export async function handleCallback(code: string, returnedState: string): Promise<boolean> {
|
||||
const verifier = sessionStorage.getItem(PKCE_KEY)
|
||||
const savedState = sessionStorage.getItem(STATE_KEY)
|
||||
sessionStorage.removeItem(PKCE_KEY)
|
||||
sessionStorage.removeItem(STATE_KEY)
|
||||
|
||||
if (!verifier || savedState !== returnedState) return false
|
||||
|
||||
const cfg = state.config ?? await fetchConfig()
|
||||
if (!cfg) return false
|
||||
|
||||
const redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectURI
|
||||
})
|
||||
})
|
||||
|
||||
if (!resp.ok) return false
|
||||
|
||||
const tokens: TokenResponse = await resp.json()
|
||||
if (!tokens.access_token) return false
|
||||
|
||||
storeTokens(tokens)
|
||||
|
||||
if (tokens.id_token) {
|
||||
const user = parseIDTokenUser(tokens.id_token)
|
||||
if (user) {
|
||||
state.user = user
|
||||
localStorage.setItem(USER_KEY, user)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function parseIDTokenUser(idToken: string): string | null {
|
||||
try {
|
||||
const payload = idToken.split('.')[1]
|
||||
const claims = JSON.parse(atob(payload))
|
||||
return claims.preferred_username || claims.email || claims.sub || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storeTokens(tokens: TokenResponse) {
|
||||
state.accessToken = tokens.access_token
|
||||
sessionStorage.setItem(SESSION_KEY, tokens.access_token)
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
state.refreshToken = tokens.refresh_token
|
||||
localStorage.setItem(REFRESH_KEY, tokens.refresh_token)
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return state.accessToken
|
||||
}
|
||||
|
||||
export function getUser(): string | null {
|
||||
return state.user
|
||||
}
|
||||
|
||||
export function isOIDCAvailable(): boolean {
|
||||
return !!(state.accessToken || state.refreshToken)
|
||||
}
|
||||
|
||||
export async function ensureToken(): Promise<string | null> {
|
||||
if (state.accessToken) return state.accessToken
|
||||
|
||||
if (state.refreshToken) {
|
||||
return refreshAccessToken()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
if (state.refreshing) return state.refreshing
|
||||
|
||||
const cfg = state.config ?? await fetchConfig()
|
||||
if (!cfg || !state.refreshToken) return null
|
||||
|
||||
state.refreshing = (async () => {
|
||||
try {
|
||||
const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: state.refreshToken
|
||||
})
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
clearTokens()
|
||||
return null
|
||||
}
|
||||
|
||||
const tokens: TokenResponse = await resp.json()
|
||||
if (!tokens.access_token) {
|
||||
clearTokens()
|
||||
return null
|
||||
}
|
||||
|
||||
storeTokens(tokens)
|
||||
return tokens.access_token
|
||||
} catch {
|
||||
clearTokens()
|
||||
return null
|
||||
} finally {
|
||||
state.refreshing = null
|
||||
}
|
||||
})()
|
||||
|
||||
return state.refreshing
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
state.accessToken = null
|
||||
state.refreshToken = null
|
||||
state.user = null
|
||||
sessionStorage.removeItem(SESSION_KEY)
|
||||
localStorage.removeItem(REFRESH_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
clearTokens()
|
||||
}
|
||||
|
||||
export function isOIDCConfigured(): boolean {
|
||||
return !!(state.accessToken || state.refreshToken)
|
||||
}
|
||||
|
||||
export async function initOIDC(): Promise<boolean> {
|
||||
if (state.accessToken) return true
|
||||
|
||||
if (state.refreshToken) {
|
||||
const token = await refreshAccessToken()
|
||||
return token !== null
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function hasPendingCallback(): boolean {
|
||||
const params = new URLSearchParams(location.search)
|
||||
return params.has('code') && params.has('state')
|
||||
}
|
||||
|
||||
export async function processPendingCallback(): Promise<boolean> {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const code = params.get('code')
|
||||
const oidcState = params.get('state')
|
||||
|
||||
if (!code || !oidcState) return false
|
||||
|
||||
const ok = await handleCallback(code, oidcState)
|
||||
|
||||
const url = new URL(location.href)
|
||||
url.searchParams.delete('code')
|
||||
url.searchParams.delete('state')
|
||||
history.replaceState(null, '', url.toString())
|
||||
|
||||
return ok
|
||||
}
|
||||
70
web/src/lib/renderers/BlastRadius.svelte
Normal file
70
web/src/lib/renderers/BlastRadius.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const grouped = $derived.by(() => {
|
||||
if (!rows) return null
|
||||
const g: Record<number, string[]> = {}
|
||||
for (const r of rows) {
|
||||
const d = Number(r.depth) || 0
|
||||
if (!g[d]) g[d] = []
|
||||
g[d].push(r.slug)
|
||||
}
|
||||
return g
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
const total = $derived(rows?.length ?? 0)
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Calculating blast radius">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Blast radius</span>
|
||||
<span class="animate-pulse text-muted-foreground">calculating…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Blast radius error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Blast radius</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if grouped && total > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Blast radius: {total} affected entities">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{total} affected entit{total === 1 ? 'y' : 'ies'}</span>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-y-auto divide-y">
|
||||
{#each Object.entries(grouped).sort(([a], [b]) => Number(a) - Number(b)) as [depth, slugs]}
|
||||
<div class="px-3 py-2">
|
||||
<div class="mb-1 font-medium text-muted-foreground">
|
||||
{Number(depth) === 1 ? 'Directly affected' : `${depth} hops`} ({slugs.length})
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each slugs as slug}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Blast radius: no affected entities">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Blast radius</span>
|
||||
<span class="text-muted-foreground">no affected entities found</span>
|
||||
</div>
|
||||
{/if}
|
||||
92
web/src/lib/renderers/ChangeLog.svelte
Normal file
92
web/src/lib/renderers/ChangeLog.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
|
||||
function shortTs(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
} catch {
|
||||
return ts?.slice(11, 19) ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function shortDate(ts: string): string {
|
||||
return ts?.slice(0, 10) ?? ''
|
||||
}
|
||||
|
||||
function shortId(id: string): string {
|
||||
if (!id) return ''
|
||||
return id.length > 12 ? id.slice(0, 12) : id
|
||||
}
|
||||
|
||||
const activityRows = $derived.by(() => {
|
||||
if (!rows) return null
|
||||
return rows.map((r) => ({
|
||||
time: shortTs(r.timestamp || r.ts || ''),
|
||||
date: shortDate(r.timestamp || r.ts || ''),
|
||||
actor: r.actor_label || shortId(r.agent_id) || r.actor_type || '',
|
||||
action: r.action || r.activity_type || '',
|
||||
toolName: r.tool_name || r.path || '',
|
||||
status: r.success ?? (r.error ? 'false' : undefined),
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading activity log">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Activity log</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Activity log error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Activity log</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if activityRows && activityRows.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Activity log: {activityRows.length} entries">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{activityRows.length} entr{activityRows.length === 1 ? 'y' : 'ies'}</span>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-y-auto divide-y">
|
||||
{#each activityRows as row}
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 font-mono">
|
||||
<span class="shrink-0 text-muted-foreground">{row.time}</span>
|
||||
{#if row.date !== activityRows[0].date}
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground/60">{row.date}</span>
|
||||
{/if}
|
||||
<span class="text-muted-foreground">{row.action}</span>
|
||||
<span class="max-w-32 truncate">{row.toolName}</span>
|
||||
<span class="text-muted-foreground/60">{row.actor}</span>
|
||||
{#if row.status === 'true'}
|
||||
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-success" title="success"></span>
|
||||
{:else if row.status === 'false'}
|
||||
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-destructive" title="error"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Activity log: no entries">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Activity log</span>
|
||||
<span class="text-muted-foreground">no entries</span>
|
||||
</div>
|
||||
{/if}
|
||||
118
web/src/lib/renderers/EntityCard.svelte
Normal file
118
web/src/lib/renderers/EntityCard.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import BoxIcon from '@lucide/svelte/icons/box'
|
||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||
import ContainerIcon from '@lucide/svelte/icons/container'
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import ZapIcon from '@lucide/svelte/icons/zap'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const entity = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result') return null
|
||||
const r = tool.result
|
||||
if (!r) return null
|
||||
if (Array.isArray(r)) return r[0]
|
||||
if (r && typeof r === 'object' && 'data' in r) return Array.isArray(r.data) ? r.data[0] : r.data
|
||||
return r
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
const slug = $derived(entity?.slug ?? tool.args?.slug_or_id ?? tool.args?.hostname ?? tool.args?.service_slug ?? '')
|
||||
|
||||
const typeIcon: Record<string, typeof BoxIcon> = {
|
||||
host: MonitorIcon,
|
||||
lxc: ContainerIcon,
|
||||
service: GlobeIcon,
|
||||
check: ZapIcon,
|
||||
}
|
||||
|
||||
const Icon = $derived(entity?.type ? (typeIcon[entity.type] ?? BoxIcon) : BoxIcon)
|
||||
|
||||
const keyAttrs = $derived.by(() => {
|
||||
if (!entity) return [] as [string, string][]
|
||||
const out: [string, string][] = []
|
||||
const skip = new Set(['slug', 'type', 'name', 'state', 'health', 'last_check', 'version', 'created_at', 'updated_at', 'maintenance_until', '__renderer', 'data', 'attrs', 'attributes', 'enrolled_at'])
|
||||
for (const k of ['mesh_ip', 'ip', 'version', 'age_pubkey', 'enrolled_at', 'last_check']) {
|
||||
const v = entity[k]
|
||||
if (v && typeof v === 'string') {
|
||||
out.push([k, k === 'age_pubkey' ? v.slice(0, 16) + '…' : v])
|
||||
}
|
||||
}
|
||||
const attrs = entity.attributes ?? entity.attrs
|
||||
if (attrs && typeof attrs === 'object') {
|
||||
for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {
|
||||
if (!skip.has(k) && v != null && v !== '') {
|
||||
out.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.slice(0, 4)
|
||||
})
|
||||
|
||||
const healthColor: Record<string, string> = {
|
||||
healthy: 'var(--success)',
|
||||
degraded: 'var(--warning)',
|
||||
down: 'var(--destructive)',
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entity: {slug || tool.name}">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-mono font-medium">{slug || tool.name}</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entity: {error}">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-mono font-medium">{slug || tool.name}</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if entity}
|
||||
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Entity: {entity.slug} — {entity.type} — {entity.health || 'no health data'}">
|
||||
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span class="font-mono font-semibold">{entity.slug ?? slug}</span>
|
||||
</div>
|
||||
{#if entity.type}
|
||||
<Badge variant="outline" class="text-[10px]">{entity.type}</Badge>
|
||||
{/if}
|
||||
{#if entity.state}
|
||||
<Badge variant="secondary" class="text-[10px]">{entity.state}</Badge>
|
||||
{/if}
|
||||
{#if entity.health && entity.health !== 'unknown'}
|
||||
<span class="flex items-center gap-1 text-muted-foreground">
|
||||
<span class="size-2 rounded-full" style="background: {healthColor[entity.health] ?? 'var(--muted-foreground)'}"></span>
|
||||
{entity.health}
|
||||
</span>
|
||||
{/if}
|
||||
{#if entity.last_check}
|
||||
<span class="text-muted-foreground">· {relativeTime(entity.last_check)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if keyAttrs.length > 0}
|
||||
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground">
|
||||
{#each keyAttrs as [k, v]}
|
||||
<span class="font-mono text-[10px]"><span class="opacity-60">{k}:</span> {v}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Entity: {slug || tool.name} — no data">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-mono font-medium">{slug || tool.name}</span>
|
||||
<span class="text-muted-foreground">no data</span>
|
||||
</div>
|
||||
{/if}
|
||||
60
web/src/lib/renderers/EntityTable.svelte
Normal file
60
web/src/lib/renderers/EntityTable.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
|
||||
const cols = $derived(rows && rows.length > 0 ? Object.keys(rows[0]).filter(k => k !== '__renderer') : [])
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entities">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Entities</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entities">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Entities</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if rows && rows.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Entities: {rows.length} results">
|
||||
<div class="max-h-56 overflow-y-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b text-muted-foreground">
|
||||
{#each cols as col}
|
||||
<th class="px-2 py-1 text-left font-medium whitespace-nowrap">{col}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row}
|
||||
<tr class="border-b last:border-0 hover:bg-muted/30">
|
||||
{#each cols as col}
|
||||
<td class="px-2 py-1 whitespace-nowrap font-mono max-w-48 truncate">{row[col] ?? '—'}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs text-muted-foreground" role="status" aria-label="Entities: no results">
|
||||
<XIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<span>No entities found</span>
|
||||
</div>
|
||||
{/if}
|
||||
93
web/src/lib/renderers/FleetSnapshot.svelte
Normal file
93
web/src/lib/renderers/FleetSnapshot.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const summary = $derived.by(() => {
|
||||
if (!rows) return null
|
||||
const health: Record<string, number> = {}
|
||||
const types: Record<string, number> = {}
|
||||
for (const r of rows) {
|
||||
health[r.health || 'unknown'] = (health[r.health || 'unknown'] || 0) + 1
|
||||
types[r.type || 'unknown'] = (types[r.type || 'unknown'] || 0) + 1
|
||||
}
|
||||
return { health, types, total: rows.length }
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
|
||||
const healthColor: Record<string, string> = {
|
||||
healthy: 'var(--success)',
|
||||
degraded: 'var(--warning)',
|
||||
down: 'var(--destructive)',
|
||||
unknown: 'var(--muted-foreground)',
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading fleet snapshot">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Fleet snapshot</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Fleet snapshot error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Fleet snapshot</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if summary && summary.total > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Fleet snapshot: {summary.total} entities">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{summary.total} entities</span>
|
||||
</div>
|
||||
<div class="px-3 py-2 space-y-2">
|
||||
<!-- Health -->
|
||||
<div>
|
||||
<div class="text-muted-foreground mb-1">Health</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{#each ['healthy', 'degraded', 'down', 'unknown'] as h}
|
||||
{#if summary.health[h]}
|
||||
<div class="flex items-center gap-1.5 font-mono">
|
||||
<span class="size-1.5 rounded-full" style="background: {healthColor[h] ?? 'var(--muted-foreground)'}"></span>
|
||||
<span class="text-muted-foreground">{h}</span>
|
||||
<span class="tabular-nums">{summary.health[h]}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Types -->
|
||||
{#if Object.keys(summary.types).length > 0}
|
||||
<div>
|
||||
<div class="text-muted-foreground mb-1">By type</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{#each Object.entries(summary.types).sort(([,a], [,b]) => b - a) as [type, count]}
|
||||
<div class="flex items-center gap-1.5 font-mono">
|
||||
<span class="text-muted-foreground">{type}</span>
|
||||
<span class="tabular-nums">{count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Fleet snapshot: no data">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Fleet snapshot</span>
|
||||
<span class="text-muted-foreground">no data</span>
|
||||
</div>
|
||||
{/if}
|
||||
82
web/src/lib/renderers/HealthSummary.svelte
Normal file
82
web/src/lib/renderers/HealthSummary.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data : null
|
||||
})
|
||||
|
||||
const counts = $derived.by(() => {
|
||||
if (!rows) return null
|
||||
const m: Record<string, number> = {}
|
||||
for (const r of rows as any[]) m[r.health || 'unknown'] = (m[r.health || 'unknown'] || 0) + 1
|
||||
return m
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
const total = $derived(counts ? Object.values(counts).reduce((a, b) => a + b, 0) : 0)
|
||||
|
||||
const bars: { label: string; count: number; color: string }[] = [
|
||||
{ label: 'healthy', count: counts?.healthy ?? 0, color: 'var(--success)' },
|
||||
{ label: 'degraded', count: counts?.degraded ?? 0, color: 'var(--warning)' },
|
||||
{ label: 'down', count: counts?.down ?? 0, color: 'var(--destructive)' },
|
||||
{ label: 'unknown', count: counts?.unknown ?? 0, color: 'var(--muted-foreground)' },
|
||||
]
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading health summary">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Health summary</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading health summary">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Health summary</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if counts && total > 0}
|
||||
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Health summary: {total} entities — healthy {counts?.healthy ?? 0}, degraded {counts?.degraded ?? 0}, down {counts?.down ?? 0}">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{total} entities</span>
|
||||
</div>
|
||||
<div class="flex h-5 w-full overflow-hidden rounded">
|
||||
{#each bars as bar}
|
||||
{#if bar.count > 0}
|
||||
<div
|
||||
style="width: {(bar.count / total) * 100}%; background: {bar.color}"
|
||||
class="flex items-center justify-center text-[9px] font-medium text-white min-w-[2rem]"
|
||||
title="{bar.label}: {bar.count}"
|
||||
>
|
||||
{bar.count}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap gap-x-3 text-muted-foreground">
|
||||
{#each bars as bar}
|
||||
{#if bar.count > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="size-1.5 rounded-full" style="background: {bar.color}"></span>
|
||||
{bar.label} {bar.count}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Health summary: no data">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Health summary</span>
|
||||
<span class="text-muted-foreground">no data</span>
|
||||
</div>
|
||||
{/if}
|
||||
70
web/src/lib/renderers/KnowledgeResults.svelte
Normal file
70
web/src/lib/renderers/KnowledgeResults.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Searching knowledge">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Knowledge</span>
|
||||
<span class="animate-pulse text-muted-foreground">searching…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Knowledge search error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Knowledge</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if rows && rows.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Knowledge: {rows.length} results">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{rows.length} result{rows.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-y-auto divide-y">
|
||||
{#each rows as row}
|
||||
<div class="px-3 py-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<FileTextIcon class="size-3 shrink-0 mt-0.5 text-muted-foreground" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono font-medium truncate">{row.title}</div>
|
||||
{#if row.snippet || row.headline}
|
||||
<div class="mt-0.5 text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{row.snippet || row.headline}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
{#if row.source}
|
||||
<span>{row.source}</span>
|
||||
{/if}
|
||||
{#if row.slug}
|
||||
<span class="font-mono opacity-60">{row.slug}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Knowledge: no results">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Knowledge</span>
|
||||
<span class="text-muted-foreground">no results</span>
|
||||
</div>
|
||||
{/if}
|
||||
85
web/src/lib/renderers/LXCList.svelte
Normal file
85
web/src/lib/renderers/LXCList.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
|
||||
const healthColor: Record<string, string> = {
|
||||
healthy: 'var(--success)',
|
||||
degraded: 'var(--warning)',
|
||||
down: 'var(--destructive)',
|
||||
}
|
||||
|
||||
function shortName(slug: string): string {
|
||||
return slug.split(':').pop() ?? slug
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading LXC containers">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">LXC containers</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading LXC containers">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">LXC containers</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if rows && rows.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="LXC containers: {rows.length} total">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{rows.length} container{rows.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b text-muted-foreground">
|
||||
<th class="px-3 py-1 text-left font-medium">Name</th>
|
||||
<th class="px-3 py-1 text-left font-medium">ID</th>
|
||||
<th class="px-3 py-1 text-left font-medium">IP</th>
|
||||
<th class="px-3 py-1 text-left font-medium">Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row}
|
||||
<tr class="border-b last:border-0 hover:bg-muted/30">
|
||||
<td class="px-3 py-1 font-mono">{shortName(row.slug)}</td>
|
||||
<td class="px-3 py-1 tabular-nums text-muted-foreground">{row.pve_id ?? '—'}</td>
|
||||
<td class="px-3 py-1 font-mono text-muted-foreground">{row.lan_ip ?? '—'}</td>
|
||||
<td class="px-3 py-1">
|
||||
{#if row.health}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="size-1.5 rounded-full" style="background: {healthColor[row.health] ?? 'var(--muted-foreground)'}"></span>
|
||||
{row.health}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="LXC containers: no data">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">LXC containers</span>
|
||||
<span class="text-muted-foreground">no data</span>
|
||||
</div>
|
||||
{/if}
|
||||
68
web/src/lib/renderers/MetricChart.svelte
Normal file
68
web/src/lib/renderers/MetricChart.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading metrics">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Metrics</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Metrics error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Metrics</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if rows && rows.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Metrics: {rows.length} samples">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{rows.length} sample{rows.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b text-muted-foreground">
|
||||
<th class="px-2 py-1 text-left font-medium">Time</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Metric</th>
|
||||
<th class="px-2 py-1 text-right font-medium">Avg</th>
|
||||
<th class="px-2 py-1 text-right font-medium">Min</th>
|
||||
<th class="px-2 py-1 text-right font-medium">Max</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row}
|
||||
<tr class="border-b last:border-0 hover:bg-muted/30">
|
||||
<td class="px-2 py-1 font-mono tabular-nums whitespace-nowrap">{row.bucket?.slice(11, 16) ?? row.bucket?.slice(0, 19) ?? '—'}</td>
|
||||
<td class="px-2 py-1 font-mono max-w-32 truncate">{row.metric ?? '—'}</td>
|
||||
<td class="px-2 py-1 font-mono tabular-nums text-right">{row.avg ?? '—'}</td>
|
||||
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.min ?? '—'}</td>
|
||||
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.max ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Metrics: no data">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Metrics</span>
|
||||
<span class="text-muted-foreground">no data</span>
|
||||
</div>
|
||||
{/if}
|
||||
9
web/src/lib/renderers/blast-radius.ts
Normal file
9
web/src/lib/renderers/blast-radius.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import BlastRadius from './BlastRadius.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) => t.name === 'get_blast_radius' || t.result?.__renderer === 'blast_radius',
|
||||
component: BlastRadius,
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user