Compare commits
88 Commits
desktop-0.
...
55781984c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 55781984c7 | |||
| 7012525ad6 | |||
| 127b939a95 | |||
| 6a8efd22bb | |||
| 876f181068 | |||
| df24cae507 | |||
| e4e426de7d | |||
| ca2ff56a25 | |||
| d6e180845c | |||
| 7ef8446825 | |||
| a9b3f844b2 | |||
| a3afbb96cf | |||
| d55bae17b9 | |||
| e3b5fdc358 | |||
| 3c3b12df5e | |||
| 3d99282897 | |||
| a8f04cc9e3 | |||
| 487f9ad358 | |||
| 3d7fa99560 | |||
| 844cfe5888 | |||
| f1dda7290a | |||
| 462fb4d77b | |||
| e3fa6736c0 | |||
| e8b30cddcf | |||
| 49dfaa77e6 | |||
| 1267c39ab1 | |||
| a5336c02e9 | |||
| dd3076a23a | |||
| 0b5b213b2a | |||
| 3b98097f58 | |||
| 3de359b85f | |||
| c5bee740ad | |||
| 337d577f00 | |||
| 5f82627fa8 | |||
| 5caf49bf48 | |||
| 24cc3b1f4e | |||
| b423cf4dea | |||
| 2ed169d239 | |||
| 44720b7b30 | |||
| c8c7705046 | |||
| 54f532f166 | |||
| b414722fc7 | |||
| cc266c238e | |||
| 9f40f19f25 | |||
| 04677fdf4b | |||
| cc6bcdceaa | |||
| 5b403141ea | |||
| 7847cdffd6 | |||
| dce19bd258 | |||
| 60effcb2fe | |||
| b446909ea5 | |||
| dd27630be3 | |||
| e78a3e9048 | |||
| 5e3e4eaf07 | |||
| 7087d1ffea | |||
| 6c7631d425 | |||
| c4ac0cb935 | |||
| 6090ef71d4 | |||
| 06c6f4eb8c | |||
| 7063c90898 | |||
| aca6b8bcc2 | |||
| eeb78ed3c6 | |||
| bcef4e6456 | |||
| 23535eac25 | |||
| bcf2b265c5 | |||
| cea67ccd15 | |||
| 2bd7de355b | |||
| 8b3fe02a10 | |||
| c8ef3793d7 | |||
| d7197c1952 | |||
| cac5524402 | |||
| 56e509d506 | |||
| 68011f9a06 | |||
| a395771960 | |||
| 5664a4bf29 | |||
| ff6608f9ea | |||
| 0271727709 | |||
| c31978042f | |||
| 5699a3f758 | |||
| 44c0145683 | |||
| de7eca8b6d | |||
| f6e2079a61 | |||
| 515c9b9174 | |||
| f1ac82255a | |||
| 62a337f3cc | |||
| 35ff3f37e1 | |||
| 8f121cfa1e | |||
| 1c3a800506 |
@@ -78,7 +78,7 @@ the REST API. Closest current equivalents for what used to live here:
|
||||
| `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 |
|
||||
| `homelab decide <action> <entity>` | No direct equivalent — classification now happens inline inside `run`, not as a separate dry-run call |
|
||||
|
||||
There is no separately-deployed "Oikos Console" anymore — the control-room
|
||||
SPA (`web/`) is the operator dashboard, served standalone (see
|
||||
|
||||
@@ -36,7 +36,7 @@ For each session determine:
|
||||
|
||||
| Signature | Root cause | Fix |
|
||||
|-----------|-----------|-----|
|
||||
| Agent: "I can't run X — only supports Y" | Missing action in `request_execution` | Add action in `internal/mcp/server.go` |
|
||||
| Agent: "I can't run X" | Missing target or capability | Use `run` with shell command — there is no fixed action enum anymore |
|
||||
| Agent: "No local knowledge on that" + no web tool | Missing `http_get` / web fetch MCP tool | Add MCP tool |
|
||||
| Empty assistant bubble (text="", no tools) | Model returned blank completion | Retry + error surfacing |
|
||||
| Non-English boilerplate refusal | Flash-tier model degradation | Response quality guard |
|
||||
@@ -75,7 +75,7 @@ Session: {id[:8]} — "{title[:60]}"
|
||||
|
||||
- `cmd/nomos/agent.go` — agent loop, tool building, response guards
|
||||
- `cmd/nomos/store.go` — session + message persistence
|
||||
- `internal/mcp/server.go` — all tool implementations including `request_execution`
|
||||
- `internal/mcp/server.go` — all tool implementations (`run`, `list_lxcs`, …)
|
||||
- `web/src/lib/components/ToolCallGroup.svelte` — tool result display
|
||||
- `nomos/SOUL.md` — agent persona and tool selection rules
|
||||
- `plans/2026-07-09-chat-sessions-improvements.md` — prior session findings
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"configurations": [
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "web", "run", "dev"],
|
||||
"runtimeExecutable": "sh",
|
||||
"runtimeArgs": ["-c", "export OIKOS_API_TOKEN=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' oikos-api-1 | sed -n 's/^OIKOS_MCP_BEARER_TOKEN=//p'); exec npm --prefix web run dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
|
||||
@@ -8,91 +8,63 @@ on:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]*'
|
||||
|
||||
jobs:
|
||||
build-ui:
|
||||
name: Build SPA
|
||||
build:
|
||||
name: Build Linux (amd64)
|
||||
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/
|
||||
- run: |
|
||||
rm -rf cmd/desktop/frontend/dist
|
||||
mkdir -p cmd/desktop/frontend/dist
|
||||
cp -r web/dist/* cmd/desktop/frontend/dist/
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
- run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
|
||||
- run: wails3 build -clean
|
||||
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- run: CGO_ENABLED=1 go build -o build/bin/Oikos .
|
||||
working-directory: cmd/desktop
|
||||
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
|
||||
tar czf oikos-desktop-linux-amd64.tar.gz Oikos
|
||||
sha256sum oikos-desktop-linux-amd64.tar.gz > oikos-desktop-linux-amd64.tar.gz.sha256
|
||||
|
||||
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
|
||||
path: |
|
||||
cmd/desktop/build/bin/oikos-desktop-linux-amd64.tar.gz
|
||||
cmd/desktop/build/bin/oikos-desktop-linux-amd64.tar.gz.sha256
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: [build-macos-arm64, build-linux-amd64]
|
||||
name: Attach to Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: oikos-desktop-darwin-arm64
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: oikos-desktop-linux-amd64
|
||||
- name: Release
|
||||
uses: https://gitea.com/actions/release-action@v1
|
||||
|
||||
- uses: https://gitea.com/actions/release-action@v1
|
||||
with:
|
||||
files: |
|
||||
oikos-desktop-darwin-arm64.zip
|
||||
oikos-desktop-linux-amd64
|
||||
oikos-desktop-linux-amd64.tar.gz
|
||||
oikos-desktop-linux-amd64.tar.gz.sha256
|
||||
api_key: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,4 +21,6 @@ web/node_modules/
|
||||
# Wails desktop app — frontend copy for embedding
|
||||
cmd/desktop/frontend/dist/
|
||||
cmd/desktop/build/
|
||||
cmd/desktop/oikos-desktop
|
||||
cmd/desktop/Oikos
|
||||
desktop
|
||||
/eval
|
||||
|
||||
23
AGENTS.md
23
AGENTS.md
@@ -110,9 +110,9 @@ Available tools (33 total):
|
||||
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
|
||||
need approval regardless of what you declare. This is the ONLY
|
||||
mutation tool — `request_execution` was retired 2026-07-14.
|
||||
`run` — the general execution primitive. Run any shell
|
||||
(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
|
||||
@@ -165,8 +165,7 @@ per the DB-as-source-of-truth plan.
|
||||
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
|
||||
`run` (the general execution primitive) 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
|
||||
@@ -204,7 +203,19 @@ wait for the 5-min timer. (This mechanism — and the server-side
|
||||
`setup-*.sh` scripts as of 2026-07-12; before that it silently matched
|
||||
nothing, so nothing auto-ran on any client via this path.)
|
||||
|
||||
## 9. When in doubt
|
||||
## 9. Versioning
|
||||
|
||||
Every commit to `main` **MUST bump the version** in the `VERSION` file at the
|
||||
repo root. The format is semver-ish: `major.minor.patch` (e.g. `0.2.3`).
|
||||
|
||||
Rules:
|
||||
- **patch** (`0.2.2` → `0.2.3`): bugfixes, small tweaks, docs-only changes
|
||||
- **minor** (`0.2.3` → `0.3.0`): new features, new tools, visible functionality
|
||||
- **major** (`0.3.0` → `1.0.0`): breaking changes (API removal, tool retirement)
|
||||
|
||||
The version is shown in the UI sidebar. The `v` prefix is added at build time.
|
||||
|
||||
## 10. When in doubt
|
||||
|
||||
Use MCP tools: `search_knowledge <query>` for narrative context,
|
||||
`get_entity <slug>` for structured data, `get_entity_knowledge <slug>` for
|
||||
|
||||
@@ -28,7 +28,7 @@ Docker stack on mac-mini and exposes an MCP server + REST API.
|
||||
| Record a discovered fact/relationship | MCP `update_entity_attributes`, `create_relationship`, `upsert_knowledge` |
|
||||
|
||||
Most MCP tools are read-only; a few mutate the knowledge graph (recording
|
||||
what you learned) or the live infrastructure (`run`, `request_execution`),
|
||||
what you learned) or the live infrastructure (`run`),
|
||||
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.
|
||||
|
||||
@@ -28,8 +28,31 @@ make build
|
||||
|
||||
# SPA dev server (proxies to api/nomos, injecting the same token)
|
||||
cd web && OIKOS_API_TOKEN=dev-token npm run dev
|
||||
|
||||
# Desktop app (macOS)
|
||||
make desktop # build .app bundle
|
||||
make install # build + install to /Applications
|
||||
./cmd/desktop/build/bin/oikos-desktop.app/Contents/MacOS/oikos-desktop # run from terminal to see logs
|
||||
```
|
||||
|
||||
### Desktop app auth
|
||||
|
||||
The desktop app uses the same API as the browser SPA. First launch:
|
||||
|
||||
1. Enter `https://oikos.hubris.network` as Server URL
|
||||
2. **Login with Authentik** tab → opens system browser → authenticate
|
||||
3. Callback page shows token → copy → paste into Token tab → Connect
|
||||
4. Token is persisted to the macOS keychain — subsequent launches skip setup
|
||||
|
||||
The app stores credentials via `github.com/zalando/go-keyring` (service: `com.hubris.oikos-desktop`).
|
||||
|
||||
### Desktop app auto-update
|
||||
|
||||
- Checks Gitea releases every 6 hours
|
||||
- System tray → **Check for Updates** triggers an immediate check
|
||||
- Download, extract, replace the app in `/Applications`, and relaunch
|
||||
- Versions are compared against the `version` const in `main.go`
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
@@ -37,6 +60,10 @@ cmd/desktop/ Wails v3 desktop app (macOS + Linux)
|
||||
main.go Thin shell: webview, system tray, notifications, auto-update
|
||||
wails.json Wails project config
|
||||
entitlements.plist macOS code-signing entitlements
|
||||
icon.png System tray icon (embedded)
|
||||
icon.icns App bundle icon (white logo on black rounded rect)
|
||||
Taskfile.yml Wails v3 build tasks
|
||||
Info.plist.template macOS bundle metadata
|
||||
cmd/oikos/ Single-binary entry point
|
||||
cmd/nomos/ Nomos MCP client gateway
|
||||
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
|
||||
@@ -88,6 +115,7 @@ docs/operations/ Runbooks (rollback, etc.)
|
||||
| `make deploy-ui` | Build + deploy the SPA to the Caddy host |
|
||||
| `make desktop` | Build the Wails desktop app for the current platform |
|
||||
| `make desktop-package` | Build + package (zip on macOS, tar.gz on Linux) |
|
||||
| `make install` | Build + install to `/Applications` (macOS) |
|
||||
| `make webhook` | Build `cmd/webhook` (deploy-webhook receiver) |
|
||||
| `make tidy` | `go mod tidy` |
|
||||
|
||||
|
||||
22
Makefile
22
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package desktop-release
|
||||
.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package install
|
||||
|
||||
BINARY := oikos
|
||||
GO ?= go
|
||||
@@ -56,20 +56,28 @@ 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
|
||||
cd cmd/desktop && CGO_ENABLED=1 go build -o build/bin/Oikos .
|
||||
|
||||
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 ;; \
|
||||
APP="cmd/desktop/build/bin/Oikos.app"; \
|
||||
rm -rf "$$APP"; \
|
||||
mkdir -p "$$APP/Contents/MacOS"; \
|
||||
mkdir -p "$$APP/Contents/Resources"; \
|
||||
cp cmd/desktop/build/bin/Oikos "$$APP/Contents/MacOS/Oikos"; \
|
||||
cp cmd/desktop/icon.icns "$$APP/Contents/Resources/icon.icns"; \
|
||||
sed 's/$$(VERSION)/0.1.0/' cmd/desktop/Info.plist.template > "$$APP/Contents/Info.plist"; \
|
||||
cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip Oikos.app ;; \
|
||||
Linux) \
|
||||
cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz oikos-desktop ;; \
|
||||
cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz Oikos ;; \
|
||||
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
|
||||
install: desktop-package ## Install to /Applications
|
||||
rm -rf /Applications/Oikos.app
|
||||
cp -r cmd/desktop/build/bin/Oikos.app /Applications/
|
||||
@echo "Installed to /Applications/Oikos.app"
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
30
cmd/desktop/Info.plist.template
Normal file
30
cmd/desktop/Info.plist.template
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Oikos</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.hubris.oikos-desktop</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Oikos</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(VERSION)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 Hubris. All rights reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
14
cmd/desktop/Taskfile.yml
Normal file
14
cmd/desktop/Taskfile.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: Build the Oikos desktop app
|
||||
cmds:
|
||||
- go build -o build/bin/Oikos .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
dev:
|
||||
summary: Run in development mode
|
||||
cmds:
|
||||
- go run .
|
||||
BIN
cmd/desktop/icon.icns
Normal file
BIN
cmd/desktop/icon.icns
Normal file
Binary file not shown.
BIN
cmd/desktop/icon.png
Normal file
BIN
cmd/desktop/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,28 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
//go:embed frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
//go:embed icon.png
|
||||
var iconPNG []byte
|
||||
|
||||
const (
|
||||
keyringService = "com.hubris.oikos-desktop"
|
||||
keyringUser = "oikos"
|
||||
@@ -30,6 +40,7 @@ const (
|
||||
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
|
||||
pollInterval = 30 * time.Second
|
||||
updateInterval = 6 * time.Hour
|
||||
oidcCallbackPort = 18901
|
||||
)
|
||||
|
||||
type OikosConfig struct {
|
||||
@@ -40,7 +51,7 @@ type OikosConfig struct {
|
||||
|
||||
// ---- ConfigService ----
|
||||
|
||||
type ConfigService struct{ app *application.App }
|
||||
type ConfigService struct{}
|
||||
|
||||
func (c *ConfigService) Name() string { return "config" }
|
||||
|
||||
@@ -54,6 +65,10 @@ func (c *ConfigService) ClearConfig() error {
|
||||
return keyring.Delete(keyringService, keyringUser)
|
||||
}
|
||||
|
||||
func (c *ConfigService) GetStoredConfig() *OikosConfig {
|
||||
return loadConfig()
|
||||
}
|
||||
|
||||
func (c *ConfigService) EnableAutoStart() error {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
|
||||
@@ -92,6 +107,203 @@ func (c *ConfigService) DisableAutoStart() error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// ---- Local OIDC server (runs alongside the webview) ----
|
||||
|
||||
type oidcSession struct {
|
||||
apiUrl string
|
||||
verifier string
|
||||
state string
|
||||
ch chan string
|
||||
}
|
||||
|
||||
var (
|
||||
oidcSessionsMu sync.Mutex
|
||||
oidcSessions = make(map[string]*oidcSession)
|
||||
)
|
||||
|
||||
func startOIDCServer() *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
cors := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
h := func(path string, handler func(http.ResponseWriter, *http.Request)) {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
cors(w, r)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
handler(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
h("/oidc/start", func(w http.ResponseWriter, r *http.Request) {
|
||||
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
|
||||
returnURL := r.URL.Query().Get("ret")
|
||||
if apiUrl == "" {
|
||||
http.Error(w, "apiUrl required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if returnURL == "" {
|
||||
returnURL = "/?desktop=1"
|
||||
}
|
||||
|
||||
oidcCfg, err := fetchOIDCConfig(apiUrl)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
verifier, challenge, _ := pkceParams()
|
||||
state := randomString(32)
|
||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort)
|
||||
|
||||
ch := make(chan string, 1)
|
||||
oidcSessionsMu.Lock()
|
||||
sessionID := randomString(16)
|
||||
oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
|
||||
oidcSessionsMu.Unlock()
|
||||
|
||||
authURL := fmt.Sprintf("%s?%s",
|
||||
oidcCfg.AuthorizationEndpoint,
|
||||
url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {oidcCfg.ClientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"code_challenge": {challenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {state},
|
||||
"scope": {"openid profile email"},
|
||||
}.Encode(),
|
||||
)
|
||||
|
||||
exec.Command("open", authURL).Start()
|
||||
|
||||
select {
|
||||
case token := <-ch:
|
||||
if token != "" {
|
||||
c := &ConfigService{}
|
||||
c.SaveConfig(apiUrl, token)
|
||||
returnURL += "&token=" + url.QueryEscape(token)
|
||||
}
|
||||
case <-time.After(5 * time.Minute):
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
||||
<meta http-equiv="refresh" content="0;url=%s">
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||||
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
||||
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
||||
</head><body><div class="card"><h1>Connected</h1><p class="ok">Redirecting back to Oikos…</p></div></body></html>`, returnURL)
|
||||
})
|
||||
|
||||
h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
gotState := r.URL.Query().Get("state")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
oidcSessionsMu.Lock()
|
||||
var session *oidcSession
|
||||
var sessionID string
|
||||
for id, s := range oidcSessions {
|
||||
if s.state == gotState {
|
||||
session = s
|
||||
sessionID = id
|
||||
break
|
||||
}
|
||||
}
|
||||
oidcSessionsMu.Unlock()
|
||||
|
||||
if session == nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("Invalid state."))
|
||||
return
|
||||
}
|
||||
|
||||
token, err := exchangeCode(
|
||||
session.apiUrl,
|
||||
code, session.verifier,
|
||||
fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort),
|
||||
)
|
||||
|
||||
oidcSessionsMu.Lock()
|
||||
delete(oidcSessions, sessionID)
|
||||
oidcSessionsMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "Token exchange failed: %v", err)
|
||||
session.ch <- ""
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||||
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
||||
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
||||
</head><body><div class="card"><h1>Connected</h1><p class="ok">You can close this window and return to Oikos.</p></div></body></html>`))
|
||||
session.ch <- token
|
||||
})
|
||||
|
||||
mux.HandleFunc("/oidc/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
|
||||
if apiUrl == "" {
|
||||
http.Error(w, "apiUrl required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg, err := fetchOIDCConfig(apiUrl)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(cfg)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/update/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
latest := fetchLatestRelease()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if latest == nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"current": version})
|
||||
return
|
||||
}
|
||||
hasAsset := false
|
||||
for _, a := range latest.Assets {
|
||||
if strings.Contains(a.Name, "darwin") {
|
||||
hasAsset = true
|
||||
updater.mu.Lock()
|
||||
updater.latestURL = a.BrowserDownloadURL
|
||||
updater.mu.Unlock()
|
||||
break
|
||||
}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"current": version,
|
||||
"latest": latest.Version,
|
||||
"has_asset": fmt.Sprintf("%t", hasAsset),
|
||||
})
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort))
|
||||
if err != nil {
|
||||
log.Printf("OIDC server: %v", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("OIDC server listening on %s", listener.Addr())
|
||||
srv := &http.Server{Handler: mux}
|
||||
go srv.Serve(listener)
|
||||
return srv
|
||||
}
|
||||
|
||||
// ---- Window persistence ----
|
||||
|
||||
type windowState struct {
|
||||
@@ -133,8 +345,6 @@ func saveWindowState(w application.Window) {
|
||||
os.WriteFile(filepath.Join(dir, "window.json"), data, 0644)
|
||||
}
|
||||
|
||||
// ---- Config loading ----
|
||||
|
||||
func loadConfig() *OikosConfig {
|
||||
data, err := keyring.Get(keyringService, keyringUser)
|
||||
if err != nil {
|
||||
@@ -148,37 +358,69 @@ func loadConfig() *OikosConfig {
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// ---- Asset handler ----
|
||||
type oidcConfig struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"client_id"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
}
|
||||
|
||||
func newAssetHandler(cfg *OikosConfig) http.Handler {
|
||||
distFS, err := fs.Sub(assets, "frontend/dist")
|
||||
func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) {
|
||||
resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config")
|
||||
if err != nil {
|
||||
log.Fatalf("embedded assets: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
|
||||
}
|
||||
var cfg oidcConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
fallback := http.FileServer(http.FS(distFS))
|
||||
func pkceParams() (verifier, challenge string, _ error) {
|
||||
v := randomString(64)
|
||||
h := sha256.Sum256([]byte(v))
|
||||
return v, base64.RawURLEncoding.EncodeToString(h[:]), nil
|
||||
}
|
||||
|
||||
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
|
||||
func randomString(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
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)
|
||||
|
||||
func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": redirectURI,
|
||||
})
|
||||
|
||||
resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
var tokens struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tokens.AccessToken == "" {
|
||||
return "", fmt.Errorf("no access_token in response")
|
||||
}
|
||||
return tokens.AccessToken, nil
|
||||
}
|
||||
|
||||
// ---- Notifications ----
|
||||
@@ -262,48 +504,181 @@ type giteaRelease struct {
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
func checkUpdates() {
|
||||
for {
|
||||
type updateState struct {
|
||||
mu sync.Mutex
|
||||
latestURL string
|
||||
}
|
||||
|
||||
var updater = &updateState{}
|
||||
|
||||
// CheckForUpdates checks Gitea releases for a newer version. If found, stores
|
||||
// the download URL and returns the latest version string (empty if current).
|
||||
func (c *ConfigService) CheckForUpdates() string {
|
||||
latest := fetchLatestRelease()
|
||||
if latest == nil || latest.Version == version {
|
||||
return ""
|
||||
}
|
||||
for _, a := range latest.Assets {
|
||||
if strings.Contains(a.Name, "darwin") {
|
||||
updater.mu.Lock()
|
||||
updater.latestURL = a.BrowserDownloadURL
|
||||
updater.mu.Unlock()
|
||||
return latest.Version
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InstallUpdate downloads the stored update, replaces the app, and restarts.
|
||||
func (c *ConfigService) InstallUpdate() error {
|
||||
updater.mu.Lock()
|
||||
url := updater.latestURL
|
||||
updater.mu.Unlock()
|
||||
if url == "" {
|
||||
return fmt.Errorf("no update available")
|
||||
}
|
||||
return doUpdate(url)
|
||||
}
|
||||
|
||||
type latestRelease struct {
|
||||
Version string
|
||||
Assets []struct {
|
||||
Name string
|
||||
BrowserDownloadURL string
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLatestRelease() *latestRelease {
|
||||
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
|
||||
if err != nil {
|
||||
time.Sleep(updateInterval)
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
defer resp.Body.Close()
|
||||
|
||||
var releases []giteaRelease
|
||||
if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 {
|
||||
time.Sleep(updateInterval)
|
||||
continue
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
latest := releases[0]
|
||||
latestVersion := strings.TrimPrefix(latest.TagName, "v")
|
||||
if latestVersion == version {
|
||||
time.Sleep(updateInterval)
|
||||
continue
|
||||
r := releases[0]
|
||||
v := strings.TrimPrefix(r.TagName, "v")
|
||||
if v == version {
|
||||
return nil
|
||||
}
|
||||
|
||||
lr := &latestRelease{Version: v}
|
||||
for _, a := range r.Assets {
|
||||
lr.Assets = append(lr.Assets, struct {
|
||||
Name string
|
||||
BrowserDownloadURL string
|
||||
}{a.Name, a.BrowserDownloadURL})
|
||||
}
|
||||
return lr
|
||||
}
|
||||
|
||||
func doUpdate(downloadURL string) error {
|
||||
tmp, err := os.CreateTemp("", "oikos-update-*.zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
resp, err := http.Get(downloadURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
extractDir, err := os.MkdirTemp("", "oikos-extract")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(extractDir)
|
||||
|
||||
cmd := exec.Command("unzip", "-o", tmp.Name(), "-d", extractDir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("unzip: %w: %s", err, out)
|
||||
}
|
||||
|
||||
newApp := filepath.Join(extractDir, "Oikos.app")
|
||||
if _, err := os.Stat(newApp); err != nil {
|
||||
return fmt.Errorf("extracted app not found: %w", err)
|
||||
}
|
||||
|
||||
currentApp := "/Applications/Oikos.app"
|
||||
if _, err := os.Stat(currentApp); os.IsNotExist(err) {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
currentApp = filepath.Dir(filepath.Dir(filepath.Dir(exe)))
|
||||
}
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
sleep 2
|
||||
rm -rf "%s"
|
||||
mv "%s" "%s"
|
||||
open "%s"
|
||||
rm "$0"
|
||||
`, currentApp, newApp, currentApp, currentApp)
|
||||
|
||||
scriptPath := filepath.Join(os.TempDir(), "oikos-update.sh")
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app := application.Get()
|
||||
if app == nil {
|
||||
exec.Command("open", scriptPath).Start()
|
||||
if app != nil {
|
||||
app.Quit()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkUpdates() {
|
||||
for {
|
||||
time.Sleep(updateInterval)
|
||||
|
||||
latest := fetchLatestRelease()
|
||||
if latest == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Version %s is available (you have %s). Download from Gitea releases.", latestVersion, version)
|
||||
for _, a := range latest.Assets {
|
||||
if strings.Contains(a.Name, "darwin") {
|
||||
updater.mu.Lock()
|
||||
updater.latestURL = a.BrowserDownloadURL
|
||||
updater.mu.Unlock()
|
||||
|
||||
app := application.Get()
|
||||
if app == nil {
|
||||
continue
|
||||
}
|
||||
msg := fmt.Sprintf("Version %s is available (you have %s).", latest.Version, version)
|
||||
app.Dialog.Info().
|
||||
SetTitle("Update Available").
|
||||
SetMessage(msg).
|
||||
Show()
|
||||
time.Sleep(updateInterval)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Main ----
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
oidcSrv := startOIDCServer()
|
||||
defer oidcSrv.Close()
|
||||
|
||||
distFS, err := fs.Sub(assets, "frontend/dist")
|
||||
if err != nil {
|
||||
log.Fatalf("embedded assets: %v", err)
|
||||
}
|
||||
|
||||
app := application.New(application.Options{
|
||||
Name: "Oikos",
|
||||
@@ -312,20 +687,19 @@ func main() {
|
||||
application.NewService(&ConfigService{}),
|
||||
},
|
||||
Assets: application.AssetOptions{
|
||||
Handler: newAssetHandler(cfg),
|
||||
Handler: application.AssetFileServerFS(distFS),
|
||||
},
|
||||
Mac: application.MacOptions{
|
||||
ApplicationShouldTerminateAfterLastWindowClosed: false,
|
||||
},
|
||||
})
|
||||
|
||||
// --- System tray ---
|
||||
systemTray := app.SystemTray.New()
|
||||
systemTray.SetLabel("Oikos")
|
||||
systemTray.SetTooltip("Oikos — Control Room")
|
||||
systemTray.SetTooltip("Oikos")
|
||||
systemTray.SetIcon(iconPNG)
|
||||
|
||||
trayMenu := application.NewMenu()
|
||||
trayMenu.Add("Open Control Room").OnClick(func(ctx *application.Context) {
|
||||
trayMenu.Add("Open Oikos").OnClick(func(ctx *application.Context) {
|
||||
for _, w := range app.Window.GetAll() {
|
||||
w.Show()
|
||||
w.Focus()
|
||||
@@ -333,7 +707,30 @@ func main() {
|
||||
})
|
||||
trayMenu.AddSeparator()
|
||||
trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
|
||||
go checkUpdates() // force immediate check on demand
|
||||
go func() {
|
||||
latest := fetchLatestRelease()
|
||||
if latest == nil {
|
||||
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
|
||||
return
|
||||
}
|
||||
for _, a := range latest.Assets {
|
||||
if strings.Contains(a.Name, "darwin") {
|
||||
updater.mu.Lock()
|
||||
updater.latestURL = a.BrowserDownloadURL
|
||||
updater.mu.Unlock()
|
||||
msg := fmt.Sprintf("Version %s is available (you have %s). Install now?", latest.Version, version)
|
||||
d := app.Dialog.Question().SetTitle("Update Available").SetMessage(msg)
|
||||
yes := d.AddButton("Install")
|
||||
yes.OnClick(func() { doUpdate(updater.latestURL) })
|
||||
no := d.AddButton("Later")
|
||||
d.SetDefaultButton(yes)
|
||||
d.SetCancelButton(no)
|
||||
d.Show()
|
||||
return
|
||||
}
|
||||
}
|
||||
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
|
||||
}()
|
||||
})
|
||||
trayMenu.AddSeparator()
|
||||
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
|
||||
@@ -341,41 +738,46 @@ func main() {
|
||||
})
|
||||
systemTray.SetMenu(trayMenu)
|
||||
|
||||
// --- Main window ---
|
||||
ws := loadWindowState()
|
||||
width, height := 1400, 900
|
||||
minWidth, minHeight := 1024, 700
|
||||
if ws != nil {
|
||||
width = ws.Width
|
||||
height = ws.Height
|
||||
}
|
||||
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: "Oikos — Control Room",
|
||||
Title: "Oikos",
|
||||
Width: width,
|
||||
Height: height,
|
||||
MinWidth: minWidth,
|
||||
MinHeight: minHeight,
|
||||
URL: "/",
|
||||
MinWidth: 1024,
|
||||
MinHeight: 700,
|
||||
URL: "/?desktop=1",
|
||||
})
|
||||
|
||||
if ws != nil {
|
||||
window.SetPosition(ws.X, ws.Y)
|
||||
window.SetSize(ws.Width, ws.Height)
|
||||
} else {
|
||||
window.Center()
|
||||
}
|
||||
|
||||
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
window.Hide()
|
||||
e.Cancel()
|
||||
})
|
||||
|
||||
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 pollDashboard(loadConfig())
|
||||
go checkUpdates()
|
||||
|
||||
err := app.Run()
|
||||
err = app.Run()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
5
cmd/desktop/tray-icon.svg
Normal file
5
cmd/desktop/tray-icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="88" height="88" viewBox="0 0 110 120" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(9, 10) scale(0.9)">
|
||||
<path fill="#ffffff" d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 690 B |
@@ -17,12 +17,12 @@ import (
|
||||
)
|
||||
|
||||
// maxIterations bounds one chat turn's tool-calling loop. Provisioning a
|
||||
// service is a long chain (research → plan → request_execution → per-step
|
||||
// service is a long chain (research → plan → run → per-step
|
||||
// install/verify run calls), so this must be generous; a full deploy with the
|
||||
// decomposed pct_create flow can legitimately need many steps. On exhaustion
|
||||
// the loop now produces a real summary (finalSummary) rather than a dead end.
|
||||
const maxIterations = 40
|
||||
const maxLLMRetries = 1
|
||||
const maxLLMRetries = 3
|
||||
|
||||
// historyWindowSize bounds how many of a session's most recent persisted
|
||||
// messages are replayed into the LLM's context on each turn — see
|
||||
@@ -129,7 +129,7 @@ func loadSoul() string {
|
||||
}
|
||||
return `You are Nomos, the steward of the oikos — the AI agent for the hubris homelab.
|
||||
You have access to MCP tools to query topology, health, knowledge, and request
|
||||
gated mutations through request_execution. Be concise. Prefer tools over guessing.`
|
||||
gated mutations through run. Be concise. Prefer tools over guessing.`
|
||||
}
|
||||
|
||||
// assentWindowDuration is how long after an operator approves a plan that
|
||||
@@ -187,9 +187,30 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject string, emit func(agentEvent)) {
|
||||
correlationID := uuid.New().String()
|
||||
|
||||
// emitError emits an error event followed by a done event. The done
|
||||
// event is CRITICAL on every terminal path: the frontend's
|
||||
// onComplete handler (chat.ts) treats a missing `done` as a severed
|
||||
// network connection and triggers an auto-reconnect → resumeSession.
|
||||
// Before this fix, a model empty-response (the most common case here)
|
||||
// returned without `done`, was misclassified as a network drop, and
|
||||
// the reconnect logic re-invoked the agent with a generic "report
|
||||
// your state" note — which caused the agent to re-propose the plan
|
||||
// and duplicate it in the sidebar (operator-reported 2026-07-14).
|
||||
// Every error return below must go through emitError so the frontend
|
||||
// shows the error inline instead of silently reconnecting.
|
||||
emitError := func(data string) {
|
||||
emit(agentEvent{Type: "error", Data: data, SessionID: sessionID})
|
||||
emit(agentEvent{Type: "done", Data: map[string]any{
|
||||
"session_id": sessionID,
|
||||
"correlation_id": correlationID,
|
||||
"iterations": 0,
|
||||
"error": true,
|
||||
}, SessionID: sessionID})
|
||||
}
|
||||
|
||||
tools, err := a.buildTools(sessionID)
|
||||
if err != nil {
|
||||
emit(agentEvent{Type: "error", Data: fmt.Sprintf("build tools: %v", err), SessionID: sessionID})
|
||||
emitError(fmt.Sprintf("build tools: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -275,8 +296,6 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
if ok {
|
||||
granted = append(granted, p.execID)
|
||||
slog.Info("nomos: chat-assent granted", "execution", p.execID, "status", status, "session", sessionID)
|
||||
emit(agentEvent{Type: "tool_use", Data: map[string]any{"name": "chat_assent", "args": map[string]any{"execution_id": p.execID}, "id": "assent-" + p.execID}, SessionID: sessionID})
|
||||
emit(agentEvent{Type: "tool_result", Data: map[string]any{"name": "chat_assent", "result": fmt.Sprintf("Approved via chat assent (%q). Status: %s.", message, status), "id": "assent-" + p.execID}, SessionID: sessionID})
|
||||
|
||||
// An explicit typed confirmation for a destructive action
|
||||
// opens a short, target-scoped window so the rest of a
|
||||
@@ -294,21 +313,33 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
}
|
||||
if len(granted) > 0 {
|
||||
a.openAssentWindow(ctx, sessionID)
|
||||
note := fmt.Sprintf("[System: the operator's message approved pending execution(s) %s via chat assent — they are now running. An assent window is now active for 30 minutes: config_mutation commands will auto-run without re-approval. Do not re-request or call request_execution/run again for these; check get_execution_status if you need the outcome. CONTINUE executing the full plan — do not stop and wait for 'continue' after each step. Only surface to the operator for destructive actions (need typed confirmation) or if you're genuinely stuck after trying alternatives.]", strings.Join(granted, ", "))
|
||||
messages = append(messages, openai.SystemMessage(note))
|
||||
// Mark approved executions as continued so the continuation
|
||||
// worker doesn't call resumeSession while the chat handler is
|
||||
// still processing "go ahead" — two concurrent LLM calls for the
|
||||
// same session cause empty responses and race conditions.
|
||||
for _, execID := range granted {
|
||||
if execUUID, perr := uuid.Parse(execID); perr == nil {
|
||||
a.store.markContinued(ctx, execUUID)
|
||||
}
|
||||
}
|
||||
// No system note. The model already sees "go ahead" in the
|
||||
// replayed history (the user message was saved to the DB before
|
||||
// chat() was called). The old note said "they are now running"
|
||||
// which made the model think work was being done for it —
|
||||
// causing empty responses (finish_reason=stop, content_len=0).
|
||||
// The approved executions are dispatched; the model will
|
||||
// continue with the remaining plan steps naturally.
|
||||
}
|
||||
if len(blocked) > 0 {
|
||||
note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run. Once they do confirm, further destructive steps on that SAME target (e.g. finishing a stop-then-destroy sequence) will auto-run for 15 minutes without asking again — but a different target always needs its own confirmation.]", strings.Join(blocked, ", "))
|
||||
messages = append(messages, openai.SystemMessage(note))
|
||||
}
|
||||
} else if assent && len(lastAssistantCalls) == 0 {
|
||||
// The operator said "proceed"/"go ahead"/"yes" but the preceding
|
||||
// assistant turn had NO pending approvals — meaning the agent
|
||||
// proposed a plan in text and asked "shall I?" without calling
|
||||
// request_execution yet. Inject a system note telling the agent
|
||||
// the operator approved — go execute the plan now.
|
||||
note := "[System: The operator approved your proposed plan. Execute it now — call request_execution or run to carry out the steps you described. Do not re-describe the plan or ask for confirmation again. The assent window is active: config_mutation commands will auto-run once you create them.]"
|
||||
messages = append(messages, openai.SystemMessage(note))
|
||||
} else if assent && len(pending) == 0 {
|
||||
// The operator said "proceed"/"go ahead"/"yes" but there are no
|
||||
// pending approvals — the agent proposed a plan (via propose_plan)
|
||||
// and asked "shall I?" Open the assent window silently. No system
|
||||
// note: the model sees "go ahead" in the replayed history and
|
||||
// responds naturally.
|
||||
a.openAssentWindow(ctx, sessionID)
|
||||
}
|
||||
|
||||
@@ -345,7 +376,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
slog.Warn("nomos: llm stream error, retrying", "error", err, "attempt", attempt+1, "session", sessionID)
|
||||
continue
|
||||
}
|
||||
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID})
|
||||
emitError(fmt.Sprintf("llm: %v", err))
|
||||
return
|
||||
}
|
||||
if len(acc.Choices) == 0 {
|
||||
@@ -353,21 +384,32 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
slog.Warn("nomos: no choices in response, retrying", "attempt", attempt+1, "session", sessionID)
|
||||
continue
|
||||
}
|
||||
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID})
|
||||
emitError("no choices in response (the model returned zero completions — likely a provider or rate-limit issue)")
|
||||
return
|
||||
}
|
||||
|
||||
msg = acc.Choices[0].Message
|
||||
finishReason := acc.Choices[0].FinishReason
|
||||
|
||||
if len(msg.ToolCalls) == 0 {
|
||||
if isRefusalOrEmpty(msg.Content) {
|
||||
if attempt < maxLLMRetries {
|
||||
slog.Warn("nomos: empty or refusal response, retrying",
|
||||
"session", sessionID, "iter", i+1, "attempt", attempt+1,
|
||||
"content_len", len(msg.Content))
|
||||
"content_len", len(msg.Content), "finish_reason", finishReason)
|
||||
continue
|
||||
}
|
||||
emit(agentEvent{Type: "error", Data: "Nomos returned an empty or unusable response — please retry.", SessionID: sessionID})
|
||||
// B.4: surface the real error context (finish_reason +
|
||||
// refusal text) instead of a generic "empty response" —
|
||||
// the operator can tell "content_filter — rephrase" from
|
||||
// "length — token limit hit" from "stop — model no-op'd".
|
||||
detail := "empty response"
|
||||
if msg.Refusal != "" {
|
||||
detail = fmt.Sprintf("refusal: %s", msg.Refusal)
|
||||
} else if finishReason != "" && finishReason != "stop" {
|
||||
detail = fmt.Sprintf("finish_reason=%s", finishReason)
|
||||
}
|
||||
emitError(fmt.Sprintf("Nomos returned an empty or unusable response (%s). Retry or rephrase.", detail))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -379,6 +421,14 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
if !sawSetGoal && !sawCompleteTask {
|
||||
a.autoCompleteTrivialTask(ctx, sessionID, msg.Content)
|
||||
}
|
||||
// Safety net: if the agent called set_goal (structured task)
|
||||
// but didn't call complete_task, and all plan steps are
|
||||
// terminal, auto-complete. The model often does the work but
|
||||
// forgets to close the loop (confirmed live: the #1 remaining
|
||||
// model reliability gap after D.1).
|
||||
if !sawCompleteTask {
|
||||
a.autoCompleteIfPlanDone(ctx, sessionID, msg.Content)
|
||||
}
|
||||
emit(agentEvent{Type: "done", Data: map[string]any{
|
||||
"session_id": sessionID,
|
||||
"usage": acc.Usage,
|
||||
@@ -388,6 +438,18 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
return
|
||||
}
|
||||
|
||||
// P3: persist intermediate reasoning. When the model produces text
|
||||
// AND tool calls in the same iteration, the text is its reasoning
|
||||
// before the tool calls — the operator saw it live via text_delta,
|
||||
// but without emitting it as a `text` event here, the persist layer
|
||||
// (main.go/continue.go) never captures it and a reload shows only
|
||||
// the final summary + a flat tool-call list, not the thinking that
|
||||
// led to each step. Emitting it lets the persist layer accumulate
|
||||
// per-iteration reasoning into the row's text field.
|
||||
if strings.TrimSpace(msg.Content) != "" {
|
||||
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
||||
}
|
||||
|
||||
slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID)
|
||||
|
||||
messages = append(messages, msg.ToParam())
|
||||
@@ -518,6 +580,9 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
summary = "I hit this turn's step limit while working. I've done a lot but couldn't wrap up cleanly — ask me for a status update and I'll summarize the current state."
|
||||
}
|
||||
emit(agentEvent{Type: "text", Data: summary, SessionID: sessionID})
|
||||
if !sawCompleteTask {
|
||||
a.autoCompleteIfPlanDone(ctx, sessionID, summary)
|
||||
}
|
||||
emit(agentEvent{Type: "done", Data: map[string]any{
|
||||
"session_id": sessionID,
|
||||
"correlation_id": correlationID,
|
||||
|
||||
@@ -52,7 +52,7 @@ func (a *agent) runIdleSweepWorker(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
slog.Info("nomos: idle sweep worker started")
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
@@ -84,6 +84,7 @@ func (a *agent) processIdleSweep(ctx context.Context) {
|
||||
"If the goal is done (or can't be completed), call complete_task now with the outcome and a "+
|
||||
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
|
||||
s.Goal, idleTaskThreshold)
|
||||
note = a.store.enrichResumeNote(ctx, s.ID, note)
|
||||
a.resumeSession(ctx, s.ID, note)
|
||||
})
|
||||
continue
|
||||
@@ -143,12 +144,25 @@ func (a *agent) processContinuations(ctx context.Context) {
|
||||
// multiple tasks in flight, one task's open window must never cover a
|
||||
// pending continuation belonging to a different task.
|
||||
if !a.store.assentWindowActive(ctx, a.agentID, p.SessionID) {
|
||||
// A finished one-off execution with no window is left as-is
|
||||
// (marked continued so we don't re-check it forever) — the
|
||||
// operator decides what happens next, as today.
|
||||
// Re-open the assent window if this session is genuinely
|
||||
// executing (plan was approved, work is in progress) — the
|
||||
// window may have expired while the execution ran. Don't
|
||||
// penalize timing: the plan was approved, the work happened,
|
||||
// the result should flow back.
|
||||
sesh, seshErr := a.store.getSession(ctx, p.SessionID)
|
||||
if seshErr == nil && sesh.Goal != "" && (sesh.Status == "executing" || sesh.Status == "planning") {
|
||||
a.openAssentWindow(ctx, p.SessionID)
|
||||
slog.Info("nomos: re-opened assent window for continuing session", "session", p.SessionID, "execution", p.ExecID)
|
||||
} else {
|
||||
// Genuinely no plan — inject a visible note so the
|
||||
// operator knows WHY the agent didn't auto-continue.
|
||||
note := fmt.Sprintf("[System: execution %s finished with status=%s, but the assent window for this session is not active. The agent will not auto-continue. Reply 'continue' or re-approve the plan to resume.]", p.ExecID, p.Status)
|
||||
body, _ := json.Marshal(map[string]any{"role": "assistant", "text": note, "auto": true})
|
||||
a.store.saveMessage(context.Background(), p.SessionID, "assistant", body)
|
||||
a.store.markContinued(ctx, p.ExecID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
a.store.markContinued(ctx, p.ExecID) // stamp first: a failure here must not cause a re-continue loop
|
||||
safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) })
|
||||
}
|
||||
@@ -213,49 +227,94 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
|
||||
// without this outer retry the operator would see nothing at all.
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
// B.3: escalate the recovery note across attempts — a transient flake
|
||||
// needs a different prompt than a model that's stuck no-op'ing. The
|
||||
// final attempt is maximally directive ("do this specific thing now").
|
||||
// B.5: back off between retries (4s, 8s) so a transient provider issue
|
||||
// has time to clear — 3 identical calls in 3 seconds just get 3
|
||||
// identical empties.
|
||||
notes := []string{
|
||||
note, // attempt 0: the original (already enriched per B.2) note
|
||||
fmt.Sprintf("[System: your previous turn produced no response. %s. Produce a response now — call the next tool or report progress in one sentence.]", note),
|
||||
fmt.Sprintf("[System: two consecutive empty responses. Stop trying to be clever. The next action is: pick the lowest-pending plan step, mark it running with update_plan_step, and call run for its target. Do that now.]"),
|
||||
}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-cctx.Done():
|
||||
return
|
||||
case <-time.After(time.Duration(2<<attempt) * time.Second): // 4s, 8s
|
||||
}
|
||||
}
|
||||
toolCalls, finalText, errText = nil, "", ""
|
||||
// P3: accumulate per-iteration reasoning instead of overwriting
|
||||
// (same fix as main.go's chat handler). Without this, a resumed
|
||||
// turn's intermediate thinking is lost on reload.
|
||||
var textParts []string
|
||||
emit := func(ev agentEvent) {
|
||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||
if m, ok := ev.Data.(map[string]any); ok {
|
||||
m["type"] = ev.Type
|
||||
// One entry per tool call: tool_use creates it,
|
||||
// tool_result merges the result into the same entry
|
||||
// (matched by id). Before this fix, both events
|
||||
// appended separate entries, doubling every tool call.
|
||||
id, _ := m["id"].(string)
|
||||
if id != "" && ev.Type == "tool_result" {
|
||||
for _, existing := range toolCalls {
|
||||
if eID, _ := existing["id"].(string); eID == id {
|
||||
for k, v := range m {
|
||||
existing[k] = v
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toolCalls = append(toolCalls, m)
|
||||
}
|
||||
}
|
||||
persist() // live: a poller sees this step land within seconds
|
||||
}
|
||||
if ev.Type == "text" {
|
||||
finalText, _ = ev.Data.(string)
|
||||
if t, ok := ev.Data.(string); ok && t != "" {
|
||||
textParts = append(textParts, t)
|
||||
finalText = strings.Join(textParts, "\n\n")
|
||||
persist()
|
||||
}
|
||||
}
|
||||
if ev.Type == "error" {
|
||||
errText, _ = ev.Data.(string)
|
||||
}
|
||||
}
|
||||
a.chatWith(cctx, sessionID, "", note, emit)
|
||||
a.chatWith(cctx, sessionID, "", notes[attempt], emit)
|
||||
if finalText != "" || len(toolCalls) > 0 {
|
||||
break
|
||||
}
|
||||
if attempt == 0 {
|
||||
slog.Warn("nomos: resume produced nothing, retrying once", "session", sessionID, "error", errText)
|
||||
if attempt < 2 {
|
||||
slog.Warn("nomos: resume produced nothing, retrying", "session", sessionID, "error", errText, "attempt", attempt+1)
|
||||
}
|
||||
}
|
||||
|
||||
if errText != "" && finalText == "" {
|
||||
slog.Error("nomos: resume produced no response after retry", "session", sessionID, "error", errText)
|
||||
// Give the task a real, operator-visible terminal state instead of
|
||||
// leaving it silently stuck at whatever status it was in (typically
|
||||
// 'executing' or 'awaiting_input') forever. Before this, a
|
||||
// permanently-failed resume was invisible beyond a log line — the
|
||||
// task board just showed a task that never changed, with nothing
|
||||
// telling the operator it needed attention. Marking it failed here
|
||||
// doesn't prevent the operator from continuing to work the task via
|
||||
// a fresh chat message afterward; it just stops the silent hang.
|
||||
summary := fmt.Sprintf("Auto-resume failed after retrying: %s", errText)
|
||||
if len(summary) > 200 {
|
||||
summary = summary[:200] + "…"
|
||||
}
|
||||
if cerr := a.store.completeTask(context.Background(), sessionID, "failure", summary); cerr != nil {
|
||||
slog.Error("nomos: failed to mark task failed after resume gave up", "session", sessionID, "error", cerr)
|
||||
// Persist a visible system note in the transcript so the
|
||||
// operator sees what happened, but do NOT auto-complete the
|
||||
// task — leave it in 'executing' so a follow-up chat message
|
||||
// can resume it. Before this fix, the task was marked 'failed'
|
||||
// here, which ended it permanently and required starting over.
|
||||
resumeFailedNote := fmt.Sprintf("[System: auto-resume failed after retrying: %s. The task is paused — send another message to continue.]", errText)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"role": "assistant",
|
||||
"text": resumeFailedNote,
|
||||
"auto": true,
|
||||
})
|
||||
if msgID != uuid.Nil {
|
||||
a.store.updateMessage(context.Background(), msgID, body)
|
||||
} else {
|
||||
// No placeholder was inserted (rare), save directly.
|
||||
a.store.saveMessage(context.Background(), sessionID, "assistant", body)
|
||||
}
|
||||
return // do not call persist() again — already persisted above
|
||||
}
|
||||
persist() // final state — same row, updated one last time with the concluding text
|
||||
}
|
||||
|
||||
370
cmd/nomos/eval/main.go
Normal file
370
cmd/nomos/eval/main.go
Normal file
@@ -0,0 +1,370 @@
|
||||
// Command nomos-eval runs golden conversation evals against a live nomos
|
||||
// gateway. It loads a YAML manifest of conversations + assertions, sends
|
||||
// each prompt to the chat endpoint, waits for the turn(s) to finish, and
|
||||
// scores assertions against the persisted transcript.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest evals/*.yaml
|
||||
//
|
||||
// The gateway must already be running (nomos serve, or the docker container).
|
||||
// Each conversation costs real OpenRouter credits (~$0.01–0.05 each).
|
||||
//
|
||||
// Manifest format — see evals/example.yaml. Assertions are scored against the
|
||||
// final transcript: tool calls made, plan steps, final session status, and
|
||||
// whether the turn completed. The runner does NOT judge text quality — only
|
||||
// structural properties that can be checked deterministically from the
|
||||
// persisted state. This is deliberate: text quality is model-dependent and
|
||||
// noisy; structure is what the Go gates + SOUL.md should enforce.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gateway := flag.String("gateway", "http://localhost:8092", "nomos gateway URL")
|
||||
manifestGlob := flag.String("manifest", "evals/*.yaml", "glob of manifest files to run")
|
||||
timeout := flag.Duration("timeout", 2*time.Minute, "per-conversation timeout")
|
||||
flag.Parse()
|
||||
|
||||
if err := health(*gateway); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "gateway not reachable at %s: %v\n", *gateway, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(*manifestGlob)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "glob %s: %v\n", *manifestGlob, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "no manifests matched %s\n", *manifestGlob)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
total, passed, failed := 0, 0, 0
|
||||
for _, f := range files {
|
||||
convs, err := loadManifest(f)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "load %s: %v\n", f, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, c := range convs {
|
||||
total++
|
||||
name := c.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("conversation-%d", total)
|
||||
}
|
||||
fmt.Printf("=== %s (from %s) ===\n", name, filepath.Base(f))
|
||||
res := runConversation(context.Background(), *gateway, c, *timeout)
|
||||
if res.Passed {
|
||||
passed++
|
||||
fmt.Printf(" ✅ PASS (%.1fs, %d tool calls)\n", res.Duration.Seconds(), res.ToolCallCount)
|
||||
} else {
|
||||
failed++
|
||||
fmt.Printf(" ❌ FAIL (%.1fs, %d tool calls)\n", res.Duration.Seconds(), res.ToolCallCount)
|
||||
}
|
||||
for _, a := range res.Assertions {
|
||||
mark := "✅"
|
||||
if !a.Passed {
|
||||
mark = "❌"
|
||||
}
|
||||
fmt.Printf(" %s %s: %s\n", mark, a.Name, a.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n=== Summary: %d/%d passed, %d failed ===\n", passed, total, failed)
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func health(gateway string) error {
|
||||
resp, err := http.Get(gateway + "/healthz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("healthz status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runConversation sends the prompt (and any followup), waits for each turn to
|
||||
// finish, then scores assertions against the final transcript.
|
||||
func runConversation(ctx context.Context, gateway string, c conversation, timeout time.Duration) convResult {
|
||||
start := time.Now()
|
||||
deadline := time.Now().Add(timeout)
|
||||
res := convResult{}
|
||||
|
||||
// Send the initial prompt (no session_id → creates a new session).
|
||||
sid, err := sendChat(ctx, gateway, "", c.Prompt)
|
||||
if err != nil {
|
||||
res.Assertions = []assertionResult{{Name: "send_prompt", Passed: false, Detail: err.Error()}}
|
||||
res.Duration = time.Since(start)
|
||||
return res
|
||||
}
|
||||
res.SessionID = sid
|
||||
|
||||
// Wait for the first turn to finish.
|
||||
if err := waitForTurn(ctx, gateway, sid, deadline); err != nil {
|
||||
res.Assertions = []assertionResult{{Name: "turn_complete", Passed: false, Detail: err.Error()}}
|
||||
res.Duration = time.Since(start)
|
||||
return res
|
||||
}
|
||||
|
||||
// Send followup if any.
|
||||
for _, fu := range c.followups() {
|
||||
if _, err := sendChat(ctx, gateway, sid, fu); err != nil {
|
||||
res.Assertions = []assertionResult{{Name: "send_followup", Passed: false, Detail: err.Error()}}
|
||||
res.Duration = time.Since(start)
|
||||
return res
|
||||
}
|
||||
if err := waitForTurn(ctx, gateway, sid, deadline); err != nil {
|
||||
res.Assertions = []assertionResult{{Name: "followup_turn_complete", Passed: false, Detail: err.Error()}}
|
||||
res.Duration = time.Since(start)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the final transcript + session state.
|
||||
transcript, session, err := fetchTranscript(ctx, gateway, sid)
|
||||
if err != nil {
|
||||
res.Assertions = []assertionResult{{Name: "fetch_transcript", Passed: false, Detail: err.Error()}}
|
||||
res.Duration = time.Since(start)
|
||||
return res
|
||||
}
|
||||
res.ToolCallCount = transcript.toolCallCount()
|
||||
res.Duration = time.Since(start)
|
||||
|
||||
// Score assertions.
|
||||
res.Assertions = scoreAssertions(c.Assertions, transcript, session)
|
||||
|
||||
res.Passed = true
|
||||
for _, a := range res.Assertions {
|
||||
if !a.Passed {
|
||||
res.Passed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// sendChat POSTs to /chat and extracts the session_id from the first SSE
|
||||
// event, then KEEPS READING the stream until it ends (the `done` event or
|
||||
// the connection closes). This is critical: the chat handler uses
|
||||
// r.Context() which cancels when the HTTP connection closes — if we stop
|
||||
// reading after the session event, the agent's work gets canceled mid-turn.
|
||||
// We must drain the full stream so the agent completes its turn server-side.
|
||||
func sendChat(ctx context.Context, gateway, sid, message string) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{"session_id": sid, "message": message})
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", gateway+"/chat", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 202 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("chat status %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
// For a reconnect (sid != ""), the body is 202 with no stream.
|
||||
if sid != "" {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return sid, nil
|
||||
}
|
||||
// Read the SSE stream, capturing the session_id from the first session
|
||||
// event, and draining the rest so the agent's turn completes. The stream
|
||||
// ends when the server closes it (after the `done` event) or when the
|
||||
// request context cancels.
|
||||
dec := newSSEReader(resp.Body)
|
||||
sessionID := ""
|
||||
for {
|
||||
ev, err := dec.next()
|
||||
if err != nil {
|
||||
if sessionID == "" {
|
||||
return "", fmt.Errorf("no session event before stream end: %w", err)
|
||||
}
|
||||
return sessionID, nil
|
||||
}
|
||||
if ev["type"] == "session" && sessionID == "" {
|
||||
if s, ok := ev["session_id"].(string); ok {
|
||||
sessionID = s
|
||||
}
|
||||
}
|
||||
// Keep reading until the stream ends — don't return early.
|
||||
}
|
||||
}
|
||||
|
||||
// waitForTurn polls the session until its last_active_at stops advancing for
|
||||
// 8 seconds (the turn ended) or the session reaches a terminal status. We
|
||||
// can't rely on status=done alone because a trivial task may auto-complete
|
||||
// while a plan-proposing task stays in 'executing' waiting for approval.
|
||||
func waitForTurn(ctx context.Context, gateway, sid string, deadline time.Time) error {
|
||||
var lastActive string
|
||||
stableSince := time.Now()
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timeout waiting for turn to complete")
|
||||
}
|
||||
_, session, err := fetchTranscript(ctx, gateway, sid)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
if session.LastActive != lastActive {
|
||||
lastActive = session.LastActive
|
||||
stableSince = time.Now()
|
||||
}
|
||||
if time.Since(stableSince) >= 8*time.Second {
|
||||
return nil // turn is idle — consider it complete
|
||||
}
|
||||
if session.Status == "done" || session.Status == "failed" {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
type transcript struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content struct {
|
||||
Text string `json:"text"`
|
||||
ToolCalls []map[string]any `json:"tool_calls"`
|
||||
} `json:"content"`
|
||||
} `json:"messages"`
|
||||
// PlanSteps is fetched from /sessions/{id}/plan (P5 plan_generations
|
||||
// assertion). Each step carries a `generation` int; distinctGenerations
|
||||
// counts the unique values. nil when the endpoint returned no plan
|
||||
// (e.g. a pure-DB Q&A with no propose_plan call).
|
||||
PlanSteps []planStep `json:"steps"`
|
||||
}
|
||||
|
||||
// planStep is one step from /sessions/{id}/plan, carrying only the fields the
|
||||
// eval needs: the generation number (P2 iteration counter).
|
||||
type planStep struct {
|
||||
Generation int `json:"generation"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (t transcript) toolCallCount() int {
|
||||
n := 0
|
||||
for _, m := range t.Messages {
|
||||
n += len(m.Content.ToolCalls)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (t transcript) toolNames() []string {
|
||||
var names []string
|
||||
for _, m := range t.Messages {
|
||||
for _, tc := range m.Content.ToolCalls {
|
||||
if name, ok := tc["name"].(string); ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// distinctGenerations counts unique plan generation values across all plan
|
||||
// steps. Used by the `plan_generations` assertion (P2 iteration). Returns 0
|
||||
// when there are no plan steps (no propose_plan was called).
|
||||
func (t transcript) distinctGenerations() int {
|
||||
seen := map[int]bool{}
|
||||
for _, s := range t.PlanSteps {
|
||||
seen[s.Generation] = true
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
type sessionState struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Outcome string `json:"outcome"`
|
||||
LastActive string `json:"last_active_at"`
|
||||
}
|
||||
|
||||
// fetchTranscript fetches the messages from /sessions/{id} (which returns
|
||||
// only session_id + messages) and the session metadata from /sessions
|
||||
// (which returns status/outcome/last_active_at for each session). P5 also
|
||||
// fetches /sessions/{id}/plan for the plan_generations assertion.
|
||||
func fetchTranscript(ctx context.Context, gateway, sid string) (transcript, sessionState, error) {
|
||||
var t transcript
|
||||
resp, err := http.Get(gateway + "/sessions/" + sid)
|
||||
if err != nil {
|
||||
return t, sessionState{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return t, sessionState{}, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &t); err != nil {
|
||||
return t, sessionState{}, err
|
||||
}
|
||||
// Fetch the plan (steps with generation numbers) for the
|
||||
// plan_generations assertion. A 404 or empty response is fine — a
|
||||
// pure-DB Q&A with no propose_plan has no plan.
|
||||
if planResp, perr := http.Get(gateway + "/sessions/" + sid + "/plan"); perr == nil {
|
||||
if planResp.StatusCode == 200 {
|
||||
pb, _ := io.ReadAll(planResp.Body)
|
||||
_ = json.Unmarshal(pb, &t) // fills t.PlanSteps via "steps" field
|
||||
}
|
||||
planResp.Body.Close()
|
||||
}
|
||||
// The detail endpoint doesn't return status/outcome — fetch from the
|
||||
// sessions list and find the matching id.
|
||||
s, err := fetchSessionMeta(ctx, gateway, sid)
|
||||
return t, s, err
|
||||
}
|
||||
|
||||
// fetchSessionMeta fetches /sessions and extracts the one matching sid.
|
||||
func fetchSessionMeta(ctx context.Context, gateway, sid string) (sessionState, error) {
|
||||
resp, err := http.Get(gateway + "/sessions")
|
||||
if err != nil {
|
||||
return sessionState{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var list struct {
|
||||
Sessions []sessionState `json:"sessions"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
return sessionState{}, err
|
||||
}
|
||||
for _, s := range list.Sessions {
|
||||
if s.ID == sid {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return sessionState{}, fmt.Errorf("session %s not found in list", sid)
|
||||
}
|
||||
|
||||
// convResult is the outcome of one conversation.
|
||||
type convResult struct {
|
||||
SessionID string
|
||||
Passed bool
|
||||
Duration time.Duration
|
||||
ToolCallCount int
|
||||
Assertions []assertionResult
|
||||
}
|
||||
|
||||
type assertionResult struct {
|
||||
Name string
|
||||
Passed bool
|
||||
Detail string
|
||||
}
|
||||
236
cmd/nomos/eval/manifest.go
Normal file
236
cmd/nomos/eval/manifest.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// conversation is one golden conversation from a manifest.
|
||||
type conversation struct {
|
||||
Name string `yaml:"name"`
|
||||
Prompt string `yaml:"prompt"`
|
||||
Followup string `yaml:"followup"` // backward compat: single followup
|
||||
Followups []string `yaml:"followups"` // P5: multi-turn followups
|
||||
Assertions []assertion `yaml:"assertions"`
|
||||
}
|
||||
|
||||
// followups returns the full list of follow-up messages, supporting both
|
||||
// the single `followup` field (backward compat) and the multi-turn
|
||||
// `followups` list.
|
||||
func (c conversation) followups() []string {
|
||||
if len(c.Followups) > 0 {
|
||||
return c.Followups
|
||||
}
|
||||
if c.Followup != "" {
|
||||
return []string{c.Followup}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assertion is one check against the final transcript. The `kind` field
|
||||
// selects the scorer; the rest are scorer-specific parameters.
|
||||
//
|
||||
// Supported kinds:
|
||||
//
|
||||
// completes — session status reached done/failed (not stuck executing)
|
||||
// outcome_is — session outcome == value (success/failure/partial)
|
||||
// no_propose_plan — propose_plan was never called
|
||||
// proposes_plan — propose_plan called >= 1 time (plan-always model; P1)
|
||||
// proposes_plan_once — propose_plan was called exactly once
|
||||
// no_duplicate_proposal — propose_plan called at most once
|
||||
// plan_before_run — the first `run` call comes after the first `propose_plan` (P1 ordering gate)
|
||||
// plan_generations — the persisted plan has exactly `value` distinct generations (P2 iteration: 1 = single, 2 = one followup)
|
||||
// writes_back — update_entity_attributes or create_relationship was called
|
||||
// max_tool_calls — total tool calls <= value
|
||||
// max_run_calls — total `run` calls <= value
|
||||
// no_run — `run` was never called
|
||||
// calls_tool — the named tool appears in the transcript
|
||||
// plan_step_count — the plan has exactly `value` steps
|
||||
// no_duplicate_complete — complete_task called at most once
|
||||
type assertion struct {
|
||||
Kind string `yaml:"kind"`
|
||||
Value any `yaml:"value"`
|
||||
}
|
||||
|
||||
// loadManifest reads a YAML file containing a list of conversations.
|
||||
func loadManifest(path string) ([]conversation, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var convs []conversation
|
||||
if err := yaml.Unmarshal(b, &convs); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return convs, nil
|
||||
}
|
||||
|
||||
// scoreAssertions evaluates each assertion against the transcript + session.
|
||||
func scoreAssertions(asserts []assertion, t transcript, s sessionState) []assertionResult {
|
||||
out := make([]assertionResult, 0, len(asserts))
|
||||
for _, a := range asserts {
|
||||
r := assertionResult{Name: a.Kind}
|
||||
r.Passed, r.Detail = scoreOne(a, t, s)
|
||||
if !r.Passed && r.Detail == "" {
|
||||
r.Detail = "assertion failed"
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scoreOne(a assertion, t transcript, s sessionState) (bool, string) {
|
||||
tools := t.toolNames()
|
||||
switch a.Kind {
|
||||
case "completes":
|
||||
if s.Status == "done" || s.Status == "failed" {
|
||||
return true, fmt.Sprintf("status=%s", s.Status)
|
||||
}
|
||||
return false, fmt.Sprintf("status=%s (not terminal)", s.Status)
|
||||
|
||||
case "outcome_is":
|
||||
want, _ := a.Value.(string)
|
||||
if s.Outcome == want {
|
||||
return true, fmt.Sprintf("outcome=%s", s.Outcome)
|
||||
}
|
||||
return false, fmt.Sprintf("outcome=%s, want %s", s.Outcome, want)
|
||||
|
||||
case "no_propose_plan":
|
||||
n := countTool(tools, "propose_plan")
|
||||
if n == 0 {
|
||||
return true, "propose_plan not called"
|
||||
}
|
||||
return false, fmt.Sprintf("propose_plan called %d time(s)", n)
|
||||
|
||||
case "proposes_plan":
|
||||
// P1 plan-always: propose_plan called >= 1 time.
|
||||
n := countTool(tools, "propose_plan")
|
||||
if n >= 1 {
|
||||
return true, fmt.Sprintf("propose_plan called %d time(s)", n)
|
||||
}
|
||||
return false, "propose_plan never called (plan-always requires >= 1)"
|
||||
|
||||
case "proposes_plan_once":
|
||||
n := countTool(tools, "propose_plan")
|
||||
if n == 1 {
|
||||
return true, "propose_plan called once"
|
||||
}
|
||||
return false, fmt.Sprintf("propose_plan called %d time(s), want 1", n)
|
||||
|
||||
case "no_duplicate_proposal":
|
||||
n := countTool(tools, "propose_plan")
|
||||
if n <= 1 {
|
||||
return true, fmt.Sprintf("propose_plan called %d time(s)", n)
|
||||
}
|
||||
return false, fmt.Sprintf("propose_plan called %d time(s), want <= 1", n)
|
||||
|
||||
case "plan_before_run":
|
||||
// P1 ordering gate: the first `run` call's global index in the
|
||||
// transcript is strictly greater than the first `propose_plan`
|
||||
// index. Both indices are over the flat tool-call list (across all
|
||||
// messages, in order).
|
||||
planIdx, runIdx := -1, -1
|
||||
for i, name := range tools {
|
||||
if name == "propose_plan" && planIdx == -1 {
|
||||
planIdx = i
|
||||
}
|
||||
if name == "run" && runIdx == -1 {
|
||||
runIdx = i
|
||||
}
|
||||
}
|
||||
if runIdx == -1 {
|
||||
return true, "run never called (ordering trivially satisfied)"
|
||||
}
|
||||
if planIdx == -1 {
|
||||
return false, "run called but propose_plan never called"
|
||||
}
|
||||
if planIdx < runIdx {
|
||||
return true, fmt.Sprintf("propose_plan at index %d before run at index %d", planIdx, runIdx)
|
||||
}
|
||||
return false, fmt.Sprintf("run at index %d before propose_plan at index %d", runIdx, planIdx)
|
||||
|
||||
case "plan_generations":
|
||||
// P2 iteration: counts distinct `generation` values in
|
||||
// session_plan_steps. 1 = single sub-task, 2 = one follow-up
|
||||
// sub-task, etc. Requires the plan endpoint to return generation
|
||||
// values; the eval fetches /sessions/{id}/plan and passes it via
|
||||
// the transcript's PlanSteps field.
|
||||
want := toInt(a.Value)
|
||||
gens := t.distinctGenerations()
|
||||
if gens == want {
|
||||
return true, fmt.Sprintf("%d plan generation(s)", gens)
|
||||
}
|
||||
return false, fmt.Sprintf("%d plan generation(s), want %d", gens, want)
|
||||
|
||||
case "writes_back":
|
||||
n := countTool(tools, "update_entity_attributes") + countTool(tools, "create_relationship")
|
||||
if n > 0 {
|
||||
return true, fmt.Sprintf("%d writeback call(s)", n)
|
||||
}
|
||||
return false, "no update_entity_attributes or create_relationship calls"
|
||||
|
||||
case "max_tool_calls":
|
||||
max := toInt(a.Value)
|
||||
if t.toolCallCount() <= max {
|
||||
return true, fmt.Sprintf("%d tool calls (<= %d)", t.toolCallCount(), max)
|
||||
}
|
||||
return false, fmt.Sprintf("%d tool calls, want <= %d", t.toolCallCount(), max)
|
||||
|
||||
case "max_run_calls":
|
||||
max := toInt(a.Value)
|
||||
n := countTool(tools, "run")
|
||||
if n <= max {
|
||||
return true, fmt.Sprintf("%d run calls (<= %d)", n, max)
|
||||
}
|
||||
return false, fmt.Sprintf("%d run calls, want <= %d", n, max)
|
||||
|
||||
case "no_run":
|
||||
n := countTool(tools, "run")
|
||||
if n == 0 {
|
||||
return true, "run not called"
|
||||
}
|
||||
return false, fmt.Sprintf("run called %d time(s)", n)
|
||||
|
||||
case "calls_tool":
|
||||
want, _ := a.Value.(string)
|
||||
n := countTool(tools, want)
|
||||
if n > 0 {
|
||||
return true, fmt.Sprintf("%s called %d time(s)", want, n)
|
||||
}
|
||||
return false, fmt.Sprintf("%s not called", want)
|
||||
|
||||
case "no_duplicate_complete":
|
||||
n := countTool(tools, "complete_task")
|
||||
if n <= 1 {
|
||||
return true, fmt.Sprintf("complete_task called %d time(s)", n)
|
||||
}
|
||||
return false, fmt.Sprintf("complete_task called %d time(s), want <= 1", n)
|
||||
|
||||
default:
|
||||
return false, fmt.Sprintf("unknown assertion kind: %s", a.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func countTool(names []string, name string) int {
|
||||
n := 0
|
||||
for _, x := range names {
|
||||
if x == name {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func toInt(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
52
cmd/nomos/eval/sse.go
Normal file
52
cmd/nomos/eval/sse.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sseReader parses a text/event-stream into a sequence of JSON events.
|
||||
// Each event is one or more "data: " lines; the lines are concatenated
|
||||
// and parsed as a single JSON object. Blank lines separate events.
|
||||
type sseReader struct {
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func newSSEReader(r io.Reader) *sseReader {
|
||||
return &sseReader{r: bufio.NewReader(r)}
|
||||
}
|
||||
|
||||
func (s *sseReader) next() (map[string]any, error) {
|
||||
var data strings.Builder
|
||||
for {
|
||||
line, err := s.r.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF && data.Len() > 0 {
|
||||
return parseEvent(data.String())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
if data.Len() > 0 {
|
||||
return parseEvent(data.String())
|
||||
}
|
||||
continue // blank line, no event buffered yet
|
||||
}
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data.WriteString(strings.TrimPrefix(line, "data: "))
|
||||
} else if strings.HasPrefix(line, "data:") {
|
||||
data.WriteString(strings.TrimPrefix(line, "data:"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseEvent(s string) (map[string]any, error) {
|
||||
var ev map[string]any
|
||||
if err := json.Unmarshal([]byte(s), &ev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
@@ -102,6 +102,21 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
// Stale execution sweep: cancels non-terminal executions older than
|
||||
// 10 minutes (orphaned by MCP timeouts — see cleanupStaleExecutions).
|
||||
safego.Go("nomos:stale-execution-sweeper", func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
st.cleanupStaleExecutions(ctx, 10*time.Minute)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
@@ -164,11 +179,30 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
http.Error(w, "bad request: "+err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if req.Message == "" {
|
||||
if req.Message == "" && req.SessionID == "" {
|
||||
http.Error(w, "message is required", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Empty message with an existing session = reconnect/resume. The
|
||||
// frontend sends this after a dropped SSE stream to re-establish the
|
||||
// connection and catch up on any auto-continuation work that happened
|
||||
// while disconnected. Route into resumeSession so the agent sees a
|
||||
// system note and reports current state.
|
||||
if req.Message == "" && req.SessionID != "" {
|
||||
slog.Info("nomos: reconnect", "session", req.SessionID)
|
||||
safego.Go("nomos:reconnect:"+req.SessionID, func() {
|
||||
base := "[System: the operator's connection was re-established. The task may have progressed in the background.]"
|
||||
note := st.enrichResumeNote(context.Background(), req.SessionID, base)
|
||||
a.resumeSession(context.Background(), req.SessionID, note)
|
||||
})
|
||||
// Return 202 so the frontend doesn't try to consume an SSE stream
|
||||
// from this POST — resumeSession writes to the DB directly and
|
||||
// the poller (already running from handleDisconnect) picks it up.
|
||||
w.WriteHeader(202)
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", 500)
|
||||
@@ -205,6 +239,16 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
sessionID = sess.ID
|
||||
}
|
||||
} else {
|
||||
// P2 iteration: if the operator sends a follow-up on a session
|
||||
// that already reached a terminal state (done/failed), reopen it
|
||||
// so a new sub-task can be framed (set_goal → propose_plan →
|
||||
// execute). reopenSession marks the prior plan's steps as
|
||||
// `replaced` (proposePlan ignores those) and clears outcome/
|
||||
// summary. Without this, propose_plan refuses the follow-up with
|
||||
// errPlanInFlight because the prior steps are all `done`. If the
|
||||
// session is still active, reopen is a no-op — the follow-up is
|
||||
// just a continuation of in-flight work.
|
||||
st.reopenSession(pctx, sessionID)
|
||||
st.touchSession(pctx, sessionID)
|
||||
}
|
||||
|
||||
@@ -224,6 +268,13 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
|
||||
|
||||
toolCalls := []map[string]any{}
|
||||
// P3: accumulate per-iteration reasoning instead of overwriting with
|
||||
// the final `text` event. The agent loop emits a `text` event for each
|
||||
// LLM iteration that produced text (intermediate reasoning before tool
|
||||
// calls + the final answer). Without accumulation, only the last `text`
|
||||
// survives in the persisted row — a reload shows the final summary but
|
||||
// not the thinking that led to each tool call.
|
||||
var textParts []string
|
||||
var finalText string
|
||||
|
||||
// Incremental persistence, mirroring resumeSession's existing
|
||||
@@ -251,17 +302,52 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||
if m, ok := ev.Data.(map[string]any); ok {
|
||||
m["type"] = ev.Type
|
||||
// One entry per tool call: tool_use creates it, tool_result
|
||||
// merges the result into the same entry (matched by id).
|
||||
// Before this fix, both events appended separate entries,
|
||||
// doubling every tool call in the persisted transcript
|
||||
// (confirmed pre-existing in d9cdcee1, v0.3.x era).
|
||||
id, _ := m["id"].(string)
|
||||
if id != "" && ev.Type == "tool_result" {
|
||||
for _, existing := range toolCalls {
|
||||
if eID, _ := existing["id"].(string); eID == id {
|
||||
for k, v := range m {
|
||||
existing[k] = v
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toolCalls = append(toolCalls, m)
|
||||
}
|
||||
}
|
||||
persist() // live: survives even if the client disconnects right after
|
||||
}
|
||||
if ev.Type == "text" {
|
||||
finalText, _ = ev.Data.(string)
|
||||
// P3: accumulate. Each `text` event is one iteration's reasoning
|
||||
// (or the final answer). Join with newlines so the persisted row
|
||||
// reads as the full transcript of what the agent said, not just
|
||||
// the last thing.
|
||||
if t, ok := ev.Data.(string); ok && t != "" {
|
||||
textParts = append(textParts, t)
|
||||
finalText = strings.Join(textParts, "\n\n")
|
||||
persist()
|
||||
}
|
||||
}
|
||||
sseEvent(w, flusher, ev)
|
||||
})
|
||||
|
||||
// B.6: if the turn ended with no text and no tool calls (the model
|
||||
// empty-response'd and all retries failed), delete the placeholder row
|
||||
// instead of persisting an empty bubble. The error event was already
|
||||
// streamed to the frontend via the 'done with error=true' event, so the
|
||||
// operator sees the error inline — an empty assistant bubble in the
|
||||
// transcript adds nothing and looks like the agent is broken.
|
||||
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
|
||||
st.deleteMessage(pctx, msgID)
|
||||
} else {
|
||||
persist() // final state — same row, updated one last time with the concluding text
|
||||
}
|
||||
|
||||
// Generate a meaningful title from the assistant's first answer
|
||||
// instead of reusing the raw user message for every session.
|
||||
@@ -318,6 +404,15 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
||||
return
|
||||
}
|
||||
|
||||
// POST /sessions/{id}/resume — the operator asks the agent to continue.
|
||||
if len(parts) == 2 && parts[1] == "resume" && r.Method == http.MethodPost {
|
||||
base := "[System: the operator wants you to continue. Pick up where you left off — execute the next step of the plan, diagnose and fix any failures, or report progress if everything is done.]"
|
||||
note := st.enrichResumeNote(context.Background(), id, base)
|
||||
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), id, note) })
|
||||
w.WriteHeader(202)
|
||||
return
|
||||
}
|
||||
|
||||
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
|
||||
// the context panel when it first opens a task; live events carry deltas
|
||||
// from there.
|
||||
@@ -383,8 +478,9 @@ func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *
|
||||
return
|
||||
}
|
||||
if a != nil {
|
||||
note := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+
|
||||
base := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+
|
||||
"Continue the task from here — do not re-ask.]", prompt, req.Answer)
|
||||
note := st.enrichResumeNote(context.Background(), sessionID, base)
|
||||
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), sessionID, note) })
|
||||
}
|
||||
w.WriteHeader(202)
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
@@ -17,6 +18,13 @@ import (
|
||||
|
||||
const maxToolResultSize = 4096
|
||||
|
||||
// errPlanInFlight is returned by proposePlan when called again after a step
|
||||
// has already started. The agent must advance the existing plan with
|
||||
// update_plan_step + run instead of re-proposing — re-proposing was the
|
||||
// source of duplicate plans in the sidebar (operator-reported 2026-07-14).
|
||||
// The caller translates this into a directive tool result.
|
||||
var errPlanInFlight = errors.New("plan already in flight")
|
||||
|
||||
type store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
@@ -33,7 +41,40 @@ func newStore(ctx context.Context, databaseURL string) (*store, error) {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
return &store{pool: pool}, nil
|
||||
s := &store{pool: pool}
|
||||
s.cleanupStaleExecutions(ctx, time.Hour)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cleanupStaleExecutions marks non-terminal executions older than maxAge as
|
||||
// cancelled. Orphaned executions accumulate when the MCP client times out
|
||||
// (30s) before the run handler's error path can mark them failed — the
|
||||
// execution entity is created before the SSH call, and a timeout kills the
|
||||
// connection before the handler runs its UPDATE. Without this, stale
|
||||
// `running` and `pending_approval` executions pile up in the DB and pollute
|
||||
// the Operations page + session rail badges. Called at startup (maxAge=1h)
|
||||
// and periodically (maxAge=10m) by the sweep worker.
|
||||
func (s *store) cleanupStaleExecutions(ctx context.Context, maxAge time.Duration) int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE executions SET status = 'cancelled',
|
||||
result = jsonb_build_object('message', 'cleaned up — stale non-terminal execution (older than ' || $1 || ')')
|
||||
WHERE status IN ('running', 'pending_approval', 'approved', 'queued')
|
||||
AND entity_id IN (
|
||||
SELECT entity_id FROM entities WHERE created_at < now() - ($2 * interval '1 second')
|
||||
)`,
|
||||
maxAge.String(), maxAge.Seconds())
|
||||
if err != nil {
|
||||
slog.Warn("nomos: stale execution cleanup failed", "error", err)
|
||||
return 0
|
||||
}
|
||||
n := int(tag.RowsAffected())
|
||||
if n > 0 {
|
||||
slog.Info("nomos: cleaned up stale executions", "count", n, "max_age", maxAge.String())
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *store) close() {
|
||||
@@ -54,6 +95,7 @@ type session struct {
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
EntityID string `json:"entity_id,omitempty"`
|
||||
PendingApprovals int `json:"pending_approvals"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastActiveAt time.Time `json:"last_active_at"`
|
||||
}
|
||||
@@ -149,6 +191,88 @@ func (s *store) updateMessage(ctx context.Context, id uuid.UUID, content json.Ra
|
||||
return err
|
||||
}
|
||||
|
||||
// deleteMessage removes a message row. Used by B.6: when a chat turn ends
|
||||
// with no text and no tool calls (the model empty-response'd and all
|
||||
// retries failed), the placeholder row is deleted instead of persisting an
|
||||
// empty assistant bubble — the error was already streamed to the frontend
|
||||
// via the 'done with error=true' event, so the operator sees it inline.
|
||||
func (s *store) deleteMessage(ctx context.Context, id uuid.UUID) {
|
||||
if s == nil || id == uuid.Nil {
|
||||
return
|
||||
}
|
||||
s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE id = $1`, id)
|
||||
}
|
||||
|
||||
// lastUserMessage returns the most recent user message text for a session,
|
||||
// or "" if none. Used to build a context-rich reconnect/resume note: instead
|
||||
// of a generic "report your state," the note can say "the operator's last
|
||||
// message was X — advance the plan" so the agent doesn't re-propose or
|
||||
// re-execute on a reconnect (the operator-reported 2026-07-14 divergence).
|
||||
func (s *store) lastUserMessage(ctx context.Context, sessionID string) string {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return ""
|
||||
}
|
||||
var content json.RawMessage
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT content FROM agent_messages
|
||||
WHERE session_id = $1 AND role = 'user'
|
||||
ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&content); err != nil {
|
||||
return ""
|
||||
}
|
||||
var m struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
return m.Text
|
||||
}
|
||||
|
||||
// hasPlanInFlight reports whether a session has a plan with at least one
|
||||
// step in a non-terminal state (pending/running). Used to direct the
|
||||
// reconnect/resume note: if a plan is in flight, the note says "advance
|
||||
// the plan with update_plan_step + run" instead of the generic "report
|
||||
// your state" (which caused the agent to re-propose and duplicate the plan
|
||||
// in the sidebar — operator-reported 2026-07-14).
|
||||
func (s *store) hasPlanInFlight(ctx context.Context, sessionID string) bool {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return false
|
||||
}
|
||||
var exists bool
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM session_plan_steps
|
||||
WHERE session_id = $1 AND status IN ('pending', 'running'))`, sessionID).Scan(&exists); err != nil {
|
||||
return false
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
// enrichResumeNote appends session context to a base resume/reconnect note:
|
||||
// the operator's last user message and, if a plan is in flight, an explicit
|
||||
// directive to advance it with update_plan_step + run (not re-propose). The
|
||||
// generic "report your state" note caused the agent to re-propose and
|
||||
// duplicate the plan on a reconnect (operator-reported 2026-07-14); this
|
||||
// enrichment gives the agent enough context to do the right thing even
|
||||
// through the reconnect path.
|
||||
func (s *store) enrichResumeNote(ctx context.Context, sessionID, base string) string {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return base
|
||||
}
|
||||
last := s.lastUserMessage(ctx, sessionID)
|
||||
inFlight := s.hasPlanInFlight(ctx, sessionID)
|
||||
if last == "" && !inFlight {
|
||||
return base
|
||||
}
|
||||
note := base
|
||||
if last != "" {
|
||||
note += fmt.Sprintf(" The operator's last message was: %q.", last)
|
||||
}
|
||||
if inFlight {
|
||||
note += " A plan is in flight — advance it with update_plan_step (status=running) + run for the next step's target. Do NOT call propose_plan again."
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
func truncateToolResults(content json.RawMessage) json.RawMessage {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(content, &m); err != nil {
|
||||
@@ -195,9 +319,19 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary,
|
||||
COALESCE(entity_id::text, ''), created_at, last_active_at
|
||||
FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
|
||||
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
|
||||
COALESCE(s.entity_id::text, ''),
|
||||
COALESCE(pa.cnt, 0),
|
||||
s.created_at, s.last_active_at
|
||||
FROM agent_sessions s
|
||||
LEFT JOIN (
|
||||
SELECT l.session_id, COUNT(*) AS cnt
|
||||
FROM nomos_plan_executions l
|
||||
JOIN executions e ON e.entity_id = l.execution_id
|
||||
WHERE e.status = 'pending_approval'
|
||||
GROUP BY l.session_id
|
||||
) pa ON pa.session_id = s.id
|
||||
ORDER BY s.last_active_at DESC LIMIT 50`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -207,7 +341,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
||||
for rows.Next() {
|
||||
var sess session
|
||||
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
||||
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
|
||||
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
|
||||
&sess.CreatedAt, &sess.LastActiveAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sess)
|
||||
@@ -215,6 +350,24 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *store) getSession(ctx context.Context, id string) (*session, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var sess session
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary,
|
||||
COALESCE(entity_id::text, ''), 0, created_at, last_active_at
|
||||
FROM agent_sessions WHERE id = $1`, id).
|
||||
Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
||||
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
|
||||
&sess.CreatedAt, &sess.LastActiveAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// getMessages returns a session's ENTIRE message history, unbounded — used
|
||||
// for the UI's own transcript view (GET /sessions/{id}), where the operator
|
||||
// should be able to see everything a task has done regardless of how long
|
||||
@@ -328,13 +481,30 @@ func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID
|
||||
return &id
|
||||
}
|
||||
|
||||
// setGoal records the task's goal and moves it into planning. Emits goal.set.
|
||||
// setGoal records the task's goal and moves it into executing. The `planning`
|
||||
// intermediate state was removed (2026-07-14) — it was indistinguishable from
|
||||
// `active` to the operator and caused sessions to appear stuck when the agent
|
||||
// called set_goal but never propose_plan (observed in production).
|
||||
//
|
||||
// P2 (2026-07-15): setGoal also replaces any prior plan steps (from a
|
||||
// previous sub-task or an incomplete first turn) as `replaced`, clearing the
|
||||
// way for a fresh propose_plan. This is the ONLY place step replacement
|
||||
// happens — not in reopenSession — because set_goal is the explicit signal
|
||||
// for "new sub-task." An approval ("go ahead") does NOT call set_goal, so it
|
||||
// won't destroy the plan the operator just approved.
|
||||
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return nil
|
||||
}
|
||||
// Replace any prior plan steps (done/running/pending/...) as `replaced`.
|
||||
// The rows are kept for the generation counter + audit trail; proposePlan
|
||||
// excludes `replaced` from its in-flight check, so the next propose_plan
|
||||
// takes the fresh-generation path.
|
||||
s.pool.Exec(ctx,
|
||||
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
||||
sessionID)
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE agent_sessions SET goal = $2, status = 'planning', last_active_at = now() WHERE id = $1`,
|
||||
`UPDATE agent_sessions SET goal = $2, status = 'executing', last_active_at = now() WHERE id = $1`,
|
||||
sessionID, goal); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -343,6 +513,40 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// reopenSession flips a terminal (done/failed) session back to `executing`
|
||||
// so a follow-up message can start a new sub-task — the iteration path
|
||||
// (P2, 2026-07-15). Without this, a completed session stays `done` forever
|
||||
// and the panel shows a stale result.
|
||||
//
|
||||
// reopenSession ONLY flips the status + clears outcome/summary. It does NOT
|
||||
// touch plan steps — that's `setGoal`'s job (see below). The reason: not
|
||||
// every follow-up is a new sub-task. An approval ("go ahead") is a
|
||||
// continuation of the current plan, and replacing its steps would destroy
|
||||
// the plan the operator just approved. `set_goal` is the explicit signal for
|
||||
// "new sub-task," so step replacement happens there, not here.
|
||||
//
|
||||
// Returns true if the session was actually reopened (was terminal), false if
|
||||
// it was already active (no-op).
|
||||
func (s *store) reopenSession(ctx context.Context, sessionID string) bool {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return false
|
||||
}
|
||||
var currentStatus string
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT status FROM agent_sessions WHERE id = $1`, sessionID).Scan(¤tStatus); err != nil {
|
||||
return false
|
||||
}
|
||||
if currentStatus != "done" && currentStatus != "failed" {
|
||||
return false
|
||||
}
|
||||
s.pool.Exec(ctx,
|
||||
`UPDATE agent_sessions SET status = 'executing', outcome = NULL, summary = NULL, last_active_at = now() WHERE id = $1`,
|
||||
sessionID)
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.reopened", s.taskEntityPtr(ctx, sessionID),
|
||||
"info", "nomos", sessionID, map[string]any{"prior_status": currentStatus})
|
||||
return true
|
||||
}
|
||||
|
||||
// planStepInput is one step as the agent proposes it.
|
||||
type planStepInput struct {
|
||||
Title string
|
||||
@@ -357,14 +561,14 @@ type planStepInput struct {
|
||||
// Two modes, chosen by whether any existing step has left 'pending':
|
||||
// - Fresh/revise (no step started yet): full replace (delete + insert). This
|
||||
// covers the first call, and a genuine re-plan before any work began.
|
||||
// - Mid-flight (some step is running/done/failed/…): APPEND the new steps
|
||||
// after the current max seq instead of wiping. The model is instructed to
|
||||
// propose the whole plan in one call, but nothing stops it from calling
|
||||
// propose_plan again per-step as it goes — a destructive replace in that
|
||||
// case would erase every already-completed step, leaving the operator
|
||||
// seeing only the most recent single step ("1/1") instead of real
|
||||
// progress. Appending makes the panel's step history correct regardless
|
||||
// of how the model chooses to call the tool.
|
||||
// - Mid-flight (some step is running/done/failed/…): REFUSE the call.
|
||||
// The agent must advance the existing plan with update_plan_step + run
|
||||
// instead of re-proposing. The previous append-mode safety net (commit
|
||||
// 5384499) preserved history but produced a confusing duplicate sidebar
|
||||
// when the agent re-proposed on "proceed" (operator-reported 2026-07-14).
|
||||
// Refusing is the correct default — the tool result tells the agent how
|
||||
// to advance, and the generation column tracks revisions if a genuine
|
||||
// re-plan is ever allowed.
|
||||
func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planStepInput) ([]map[string]any, error) {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return nil, nil
|
||||
@@ -377,17 +581,52 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
||||
|
||||
var startSeq int
|
||||
var anyStarted bool
|
||||
// `replaced` steps (from a prior plan generation superseded by a
|
||||
// follow-up sub-task — see reopenSession) are excluded: they prove a
|
||||
// prior plan was completed and superseded, not that a plan is in flight.
|
||||
// Without this exclusion, reopenSession's `replaced` marking would be
|
||||
// useless — propose_plan would still refuse on the follow-up.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status <> 'pending'), false)
|
||||
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status NOT IN ('pending', 'replaced')), false)
|
||||
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&startSeq, &anyStarted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !anyStarted {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1`, sessionID); err != nil {
|
||||
if anyStarted {
|
||||
// A plan is already in flight (a step is running/done/failed/...).
|
||||
// Refuse the re-proposal — the agent must advance with
|
||||
// update_plan_step + run. The caller surfaces a directive.
|
||||
return nil, errPlanInFlight
|
||||
}
|
||||
// Fresh/revise: mark any prior PENDING steps as `replaced` (not DELETE).
|
||||
// This preserves the rows for the generation counter (MAX(generation)+1
|
||||
// below) and the plan_generations eval assertion. Without this, a first
|
||||
// plan that was proposed but never executed (all pending) would be
|
||||
// wiped, resetting the counter to 1 — making a follow-up's plan look
|
||||
// like generation 1 instead of 2. `replaced` steps are excluded from
|
||||
// the anyStarted check above, so they don't block the fresh proposal.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`,
|
||||
sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startSeq = 0
|
||||
// startSeq keeps the max(seq) from the query above: if prior steps
|
||||
// exist (replaced or done), the new generation's steps start after them
|
||||
// (no seq collisions across generations). If no rows exist (first plan),
|
||||
// startSeq is 0 and the first step is seq 1.
|
||||
|
||||
// Resolve the generation number for this plan. Generation 1 is the
|
||||
// initial plan; a genuine revise (which currently goes through the same
|
||||
// fresh-start path above because all steps were pending) resets to 1
|
||||
// since the DELETE wiped the prior rows. The column is wired here so a
|
||||
// future explicit mid-flight revise path can increment it.
|
||||
var nextGen int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(generation), 0) + 1
|
||||
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&nextGen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// After the DELETE above, no rows remain, so MAX(generation) is NULL →
|
||||
// nextGen = 1. (Keep the query for the future revise path; it's cheap.)
|
||||
|
||||
out := make([]map[string]any, 0, len(steps))
|
||||
for i, st := range steps {
|
||||
@@ -398,14 +637,15 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
||||
seq := startSeq + i + 1
|
||||
var id uuid.UUID
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
sessionID, seq, st.Title, st.Detail, targetSlug).Scan(&id); err != nil {
|
||||
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug, generation)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
sessionID, seq, st.Title, st.Detail, targetSlug, nextGen).Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": id.String(), "seq": seq, "title": st.Title,
|
||||
"detail": st.Detail, "target_slug": st.TargetSlug,
|
||||
"generation": nextGen,
|
||||
})
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
@@ -416,10 +656,10 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
||||
return nil, err
|
||||
}
|
||||
// Event after commit so subscribers only ever see a persisted plan.
|
||||
// appended=true tells the panel to add these steps to its existing list
|
||||
// rather than replace it (mirrors the mid-flight append above).
|
||||
// appended=false (always now — we refuse mid-flight re-proposals) tells
|
||||
// the panel to replace its list with these steps.
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.proposed", s.taskEntityPtr(ctx, sessionID),
|
||||
"info", "nomos", sessionID, map[string]any{"steps": out, "appended": anyStarted})
|
||||
"info", "nomos", sessionID, map[string]any{"steps": out, "appended": false, "generation": nextGen})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -428,6 +668,11 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
||||
// plan.step.finished (terminal) so the panel advances live. The execution link
|
||||
// is also what lets the api auto-close the step when the execution finishes
|
||||
// (see closePlanStepForExecution).
|
||||
//
|
||||
// Completion ordering (done/failed/skipped/blocked) is enforced: a step cannot
|
||||
// be marked complete while an earlier step is still pending, preventing the
|
||||
// agent from marking step 5 done before step 4 (observed in production: the
|
||||
// agent rushed to close all steps in a final turn, in reverse order).
|
||||
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID string) error {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return nil
|
||||
@@ -436,9 +681,22 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
|
||||
switch status {
|
||||
case "running":
|
||||
stamp = ", started_at = COALESCE(started_at, now())"
|
||||
case "done", "failed", "skipped", "blocked":
|
||||
case "done", "failed", "skipped", "blocked", "replaced":
|
||||
stamp = ", finished_at = now()"
|
||||
}
|
||||
// Completion ordering: for terminal states, check that no earlier step
|
||||
// is still pending. Running steps can start out of order (the agent
|
||||
// may dispatch parallel work), but completion must be sequential.
|
||||
if status == "done" || status == "failed" || status == "skipped" || status == "blocked" {
|
||||
var blockedBy int
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(MIN(seq), 0)
|
||||
FROM session_plan_steps
|
||||
WHERE session_id = $1 AND seq < $2 AND status = 'pending'`,
|
||||
sessionID, seq).Scan(&blockedBy); err == nil && blockedBy > 0 {
|
||||
return fmt.Errorf("cannot complete step %d — step %d is still pending", seq, blockedBy)
|
||||
}
|
||||
}
|
||||
var execPtr *uuid.UUID
|
||||
if id, err := uuid.Parse(execID); err == nil {
|
||||
execPtr = &id
|
||||
@@ -473,14 +731,64 @@ func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, s
|
||||
return nil
|
||||
}
|
||||
|
||||
// errTaskAlreadyComplete is returned by completeTask when the session is
|
||||
// already in a terminal state (done/failed/partial). The agent sometimes
|
||||
// re-calls complete_task after a UI clarification (operator-reported
|
||||
// 2026-07-14) — without this guard, the re-completion produces duplicate
|
||||
// knowledge entries and erodes audit-log clarity. The caller translates this
|
||||
// into a directive tool result.
|
||||
var errTaskAlreadyComplete = errors.New("task already complete")
|
||||
|
||||
// completeTask sets a task's terminal state, outcome, and one-line summary,
|
||||
// mirrors the outcome onto the task entity's attributes (so the board/graph
|
||||
// show it), and publishes task.status for the live context panel. outcome is
|
||||
// success|failure|partial; status is derived (failure → failed, else done).
|
||||
// Returns errTaskAlreadyComplete if the session is already terminal — the
|
||||
// agent must not re-complete a finished task.
|
||||
func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary string) error {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// C.1: reject re-completion of an already-terminal session. The agent
|
||||
// sometimes re-calls complete_task after a UI clarification ("the sidebar
|
||||
// differs") — without this guard, the re-completion duplicates knowledge
|
||||
// entries and produces a confusing audit trail.
|
||||
var currentStatus string
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT status FROM agent_sessions WHERE id = $1`, sessionID).Scan(¤tStatus); err != nil {
|
||||
// Session doesn't exist or query failed — let the rest of the
|
||||
// function proceed; it'll fail safely on the UPDATE below.
|
||||
} else if currentStatus == "done" || currentStatus == "failed" {
|
||||
return errTaskAlreadyComplete
|
||||
}
|
||||
|
||||
// Auto-cancel any executions still in pending_approval/approved/queued
|
||||
// state for this session — preventing orphaned approvals (observed in
|
||||
// production: 4 approvals left open after session completed).
|
||||
var cancelledCount int
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
WITH cancelled AS (
|
||||
UPDATE executions SET status = 'cancelled',
|
||||
result = '{"message": "task completed — auto-cancelled"}'::jsonb
|
||||
WHERE entity_id IN (
|
||||
SELECT execution_id FROM nomos_plan_executions WHERE session_id = $1
|
||||
) AND status IN ('pending_approval', 'approved', 'queued')
|
||||
RETURNING entity_id
|
||||
)
|
||||
SELECT COUNT(*) FROM cancelled
|
||||
`, sessionID).Scan(&cancelledCount); err != nil {
|
||||
slog.Warn("nomos: completeTask failed to cancel orphaned executions", "session", sessionID, "error", err)
|
||||
}
|
||||
|
||||
// Mark all continuations done so the worker won't try to feed them back.
|
||||
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now()
|
||||
WHERE session_id = $1 AND continued_at IS NULL`, sessionID)
|
||||
|
||||
// Clean up assent and destructive window keys from autonomy_settings.
|
||||
s.pool.Exec(ctx, `DELETE FROM autonomy_settings
|
||||
WHERE key LIKE '%:' || $1`, sessionID)
|
||||
|
||||
status := "done"
|
||||
if outcome == "failure" {
|
||||
status = "failed"
|
||||
@@ -504,10 +812,52 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
||||
severity = "warning"
|
||||
}
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
|
||||
map[string]any{"status": status, "outcome": outcome, "summary": summary})
|
||||
map[string]any{"status": status, "outcome": outcome, "summary": summary,
|
||||
"cancelled_executions": cancelledCount})
|
||||
return nil
|
||||
}
|
||||
|
||||
// hadEntityWriteback checks whether this session called update_entity_attributes
|
||||
// or create_relationship — used by complete_task to warn the agent when it
|
||||
// forgot to persist entity facts (the #1 cause of knowledge graph drift).
|
||||
func (s *store) hadEntityWriteback(ctx context.Context, sessionID string) bool {
|
||||
if s == nil || sessionID == "" {
|
||||
return true // fail safe: don't warn when we can't check
|
||||
}
|
||||
var count int
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM agent_activity
|
||||
WHERE session_id = $1
|
||||
AND tool_name IN ('update_entity_attributes', 'create_relationship')
|
||||
AND success = true`, sessionID).Scan(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// hadDiscovery checks whether this session ran `run` successfully against a
|
||||
// real target — i.e. discovered live state (versions, package counts, host
|
||||
// facts, service status) that the DB didn't have. Used by complete_task to
|
||||
// refuse success when discovery happened but no writeback followed (the
|
||||
// knowledge-loop drift the prior warnings failed to close — the agent
|
||||
// ignored advisory text, so D.1 makes it structural).
|
||||
//
|
||||
// Only `run` counts as discovery here, NOT get_entity/list_lxcs/etc. — those
|
||||
// are DB lookups, not new facts. A trivial Q&A ("status of lxc:dns?") that
|
||||
// only calls get_entity is a degenerate case (SOUL.md: "Don't invent
|
||||
// attributes that don't exist") and must NOT be blocked. Only sessions that
|
||||
// actually executed against a live target get the writeback gate.
|
||||
func (s *store) hadDiscovery(ctx context.Context, sessionID string) bool {
|
||||
if s == nil || sessionID == "" {
|
||||
return false // fail safe: don't block when we can't check
|
||||
}
|
||||
var count int
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM agent_activity
|
||||
WHERE session_id = $1
|
||||
AND tool_name = 'run'
|
||||
AND success = true`, sessionID).Scan(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// staleGoalSession is a goal-bearing task that's gone idle without reaching
|
||||
// a terminal state — the idle-sweep worker's work list (fix 2+3 of
|
||||
// plans/2026-07-11-task-completion-safety-net.md).
|
||||
@@ -562,6 +912,42 @@ func (s *store) bumpCompletionNudge(ctx context.Context, sessionID string) error
|
||||
return err
|
||||
}
|
||||
|
||||
// allPlanStepsTerminal reports whether every plan step for this session is in
|
||||
// a terminal state (done/failed/replaced/skipped/blocked) — i.e. no step is
|
||||
// still pending or running. Used by autoCompleteIfPlanDone to auto-close a
|
||||
// task when the agent did all the work but forgot to call complete_task.
|
||||
// Returns false if there are no plan steps at all (no plan was proposed).
|
||||
func (s *store) allPlanStepsTerminal(ctx context.Context, sessionID string) bool {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return false
|
||||
}
|
||||
var total, terminal int
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*), COUNT(*) FILTER (WHERE status IN ('done', 'failed', 'replaced', 'skipped', 'blocked'))
|
||||
FROM session_plan_steps WHERE session_id = $1`,
|
||||
sessionID).Scan(&total, &terminal); err != nil {
|
||||
return false
|
||||
}
|
||||
return total > 0 && total == terminal
|
||||
}
|
||||
|
||||
// hasPendingApprovals reports whether this session has any executions in
|
||||
// pending_approval state. Used by autoCompleteIfPlanDone to avoid closing a
|
||||
// session that's blocked waiting for operator approval — the agent hit the
|
||||
// P5 gate and can't continue until the operator responds.
|
||||
func (s *store) hasPendingApprovals(ctx context.Context, sessionID string) bool {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return false
|
||||
}
|
||||
var count int
|
||||
s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM nomos_plan_executions pe
|
||||
JOIN executions ex ON ex.entity_id = pe.execution_id
|
||||
WHERE pe.session_id = $1 AND ex.status = 'pending_approval'`,
|
||||
sessionID).Scan(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// planStep is a persisted plan step, as returned to the frontend for hydration
|
||||
// (the panel otherwise only sees steps live via plan.proposed/plan.step.*).
|
||||
type planStep struct {
|
||||
@@ -574,6 +960,7 @@ type planStep struct {
|
||||
TargetSlug *string `json:"target_slug,omitempty"`
|
||||
StartedAt *string `json:"started_at,omitempty"`
|
||||
FinishedAt *string `json:"finished_at,omitempty"`
|
||||
Generation int `json:"generation"`
|
||||
}
|
||||
|
||||
// getPlanSteps returns a task's plan in order — REST hydration for the context
|
||||
@@ -585,7 +972,7 @@ func (s *store) getPlanSteps(ctx context.Context, sessionID string) ([]planStep,
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id::text, seq, title, detail, status,
|
||||
execution_id::text, target_slug,
|
||||
started_at::text, finished_at::text
|
||||
started_at::text, finished_at::text, generation
|
||||
FROM session_plan_steps WHERE session_id = $1 ORDER BY seq`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -596,7 +983,7 @@ func (s *store) getPlanSteps(ctx context.Context, sessionID string) ([]planStep,
|
||||
var st planStep
|
||||
var execID, target, started, finished *string
|
||||
if err := rows.Scan(&st.ID, &st.Seq, &st.Title, &st.Detail, &st.Status,
|
||||
&execID, &target, &started, &finished); err != nil {
|
||||
&execID, &target, &started, &finished, &st.Generation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.ExecutionID, st.TargetSlug, st.StartedAt, st.FinishedAt = execID, target, started, finished
|
||||
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
@@ -159,19 +161,19 @@ func TestGetRecentMessages_Truncation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProposePlan_AppendVsReplace is the concrete proof for the plan-append
|
||||
// fix (commit 5384499, "plan panel showed only the latest step, not the full
|
||||
// plan"): proposePlan must REPLACE the step list only while every existing
|
||||
// step is still 'pending' (a genuine pre-execution revision), and APPEND
|
||||
// once any step has started — otherwise a model that calls propose_plan once
|
||||
// per step (rather than once with the full list, as instructed) erases every
|
||||
// already-completed step each time, and the operator only ever sees the
|
||||
// latest single step instead of real progress.
|
||||
func TestProposePlan_AppendVsReplace(t *testing.T) {
|
||||
// TestProposePlan_RefuseInFlight is the concrete proof for the plan-drift
|
||||
// fix (2026-07-14, "plan added twice in the sidebar"): proposePlan must
|
||||
// REPLACE the step list only while every existing step is still 'pending'
|
||||
// (a genuine pre-execution revision), and REFUSE the call once any step has
|
||||
// started. The prior append-mode safety net (commit 5384499) preserved
|
||||
// history but duplicated the plan in the sidebar when the agent re-proposed
|
||||
// on "proceed". Refusing is the correct default — the agent must advance
|
||||
// with update_plan_step + run.
|
||||
func TestProposePlan_RefuseInFlight(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := s.createSession(ctx, "plan append test")
|
||||
sess, err := s.createSession(ctx, "plan refuse test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
@@ -185,39 +187,37 @@ func TestProposePlan_AppendVsReplace(t *testing.T) {
|
||||
if len(out1) != 1 || out1[0]["seq"] != 1 {
|
||||
t.Fatalf("proposePlan #1 = %+v, want one step at seq 1", out1)
|
||||
}
|
||||
if out1[0]["generation"] != 1 {
|
||||
t.Fatalf("proposePlan #1 generation = %v, want 1", out1[0]["generation"])
|
||||
}
|
||||
|
||||
// Mark step 1 as started.
|
||||
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
|
||||
t.Fatalf("updatePlanStep: %v", err)
|
||||
}
|
||||
|
||||
// Second call, simulating a model that (against instructions) calls
|
||||
// propose_plan again per-step instead of once with the full list: since
|
||||
// step 1 has left 'pending', this MUST append, not replace.
|
||||
out2, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step B"}})
|
||||
if err != nil {
|
||||
t.Fatalf("proposePlan #2: %v", err)
|
||||
}
|
||||
if len(out2) != 1 || out2[0]["seq"] != 2 {
|
||||
t.Fatalf("proposePlan #2 = %+v, want one step at seq 2 (appended after the running step 1)", out2)
|
||||
// Second call, simulating a model that re-proposes mid-flight (the
|
||||
// operator-reported "proceed" bug): since step 1 has left 'pending',
|
||||
// this MUST refuse with errPlanInFlight, not append or replace.
|
||||
_, err = s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step B"}})
|
||||
if !errors.Is(err, errPlanInFlight) {
|
||||
t.Fatalf("proposePlan #2: err = %v, want errPlanInFlight (refuse mid-flight re-proposal)", err)
|
||||
}
|
||||
|
||||
// The original step 1 must be untouched — not erased, not appended to.
|
||||
steps, err := s.getPlanSteps(ctx, sess.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getPlanSteps: %v", err)
|
||||
}
|
||||
if len(steps) != 2 {
|
||||
t.Fatalf("got %d persisted steps, want 2 (step 1 must survive the second propose_plan call)", len(steps))
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("got %d persisted steps, want 1 (refused call must not mutate the plan)", len(steps))
|
||||
}
|
||||
if steps[0].Title != "Step A" || steps[0].Status != "running" {
|
||||
t.Errorf("step 1 = %+v, want Step A still running (not erased)", steps[0])
|
||||
}
|
||||
if steps[1].Title != "Step B" || steps[1].Status != "pending" {
|
||||
t.Errorf("step 2 = %+v, want Step B pending", steps[1])
|
||||
t.Errorf("step 1 = %+v, want Step A still running (refused call must not touch it)", steps[0])
|
||||
}
|
||||
|
||||
// Third call BEFORE anything runs on a fresh session: every step is
|
||||
// still pending, so this must REPLACE, not append.
|
||||
// still pending, so this must REPLACE, not refuse.
|
||||
sess2, err := s.createSession(ctx, "plan replace test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
@@ -233,6 +233,81 @@ func TestProposePlan_AppendVsReplace(t *testing.T) {
|
||||
t.Fatalf("getPlanSteps: %v", err)
|
||||
}
|
||||
if len(revisedSteps) != 1 || revisedSteps[0].Title != "Revised" {
|
||||
t.Fatalf("got %+v, want a single 'Revised' step (pre-execution revise must replace, not append)", revisedSteps)
|
||||
t.Fatalf("got %+v, want a single 'Revised' step (pre-execution revise must replace, not refuse)", revisedSteps)
|
||||
}
|
||||
if revisedSteps[0].Generation != 1 {
|
||||
t.Fatalf("revised step generation = %d, want 1 (fresh-start after DELETE resets generation)", revisedSteps[0].Generation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHadDiscoveryAndWriteback is the store-level proof for D.1 (refuse
|
||||
// complete_task when discovery ran without writeback). hadDiscovery must
|
||||
// report true only after a successful `run` call; hadEntityWriteback must
|
||||
// report true only after a successful update_entity_attributes or
|
||||
// create_relationship call. The D.1 gate in tasks.go combines these: refuse
|
||||
// success when hadDiscovery && !hadEntityWriteback.
|
||||
func TestHadDiscoveryAndWriteback(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := s.createSession(ctx, "discovery test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
// Before any tool calls: no discovery, no writeback.
|
||||
if s.hadDiscovery(ctx, sess.ID) {
|
||||
t.Fatal("hadDiscovery = true before any tool calls, want false")
|
||||
}
|
||||
if s.hadEntityWriteback(ctx, sess.ID) {
|
||||
t.Fatal("hadEntityWriteback = true before any tool calls, want false")
|
||||
}
|
||||
|
||||
// A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback.
|
||||
agentID := uuid.New()
|
||||
s.logActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1")
|
||||
if !s.hadDiscovery(ctx, sess.ID) {
|
||||
t.Fatal("hadDiscovery = false after a successful run call, want true")
|
||||
}
|
||||
if s.hadEntityWriteback(ctx, sess.ID) {
|
||||
t.Fatal("hadEntityWriteback = true after only a run call, want false")
|
||||
}
|
||||
|
||||
// A failed run call should NOT count as discovery (no facts learned).
|
||||
sess2, err := s.createSession(ctx, "failed discovery test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
s.logActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2")
|
||||
if s.hadDiscovery(ctx, sess2.ID) {
|
||||
t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)")
|
||||
}
|
||||
|
||||
// A get_entity call should NOT count as discovery (DB lookup, not live state).
|
||||
sess3, err := s.createSession(ctx, "lookup test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
s.logActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3")
|
||||
if s.hadDiscovery(ctx, sess3.ID) {
|
||||
t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)")
|
||||
}
|
||||
|
||||
// update_entity_attributes sets hadEntityWriteback.
|
||||
sess4, err := s.createSession(ctx, "writeback test")
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
s.logActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4")
|
||||
if !s.hadEntityWriteback(ctx, sess4.ID) {
|
||||
t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true")
|
||||
}
|
||||
// And the discovery+writeback combination (the conv3 scenario).
|
||||
s.logActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5")
|
||||
if !s.hadDiscovery(ctx, sess4.ID) {
|
||||
t.Fatal("hadDiscovery = false after run+writeback, want true")
|
||||
}
|
||||
if !s.hadEntityWriteback(ctx, sess4.ID) {
|
||||
t.Fatal("hadEntityWriteback = false after run+writeback, want true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -34,20 +35,18 @@ func taskToolDefs() []toolDef {
|
||||
},
|
||||
{
|
||||
Name: "propose_plan",
|
||||
Description: "Lay out ALL the ordered steps you'll take to reach the goal, in ONE " +
|
||||
"call, listing every step end-to-end — not just the next one. The operator " +
|
||||
"sees the full list in the context panel and watches it progress; a plan " +
|
||||
"with only 1 step looks broken to them even if you intend to add more later. " +
|
||||
"Your FIRST step should be research (prior knowledge, relations, blast radius " +
|
||||
"— not just this target's status) and your LAST step should be writing back " +
|
||||
"what you learned (update_entity_attributes / create_relationship / " +
|
||||
"upsert_knowledge) BEFORE complete_task — this is what keeps the knowledge " +
|
||||
"graph from drifting out of date. " +
|
||||
"Call this ONCE, before you start executing (after gathering what you need). " +
|
||||
"As you work, call update_plan_step (not propose_plan again) to advance each " +
|
||||
"step. Only re-call propose_plan if the plan itself has fundamentally changed " +
|
||||
"(e.g. a new approach is needed) — in that case new steps are appended after " +
|
||||
"whatever already ran, never erasing completed work.",
|
||||
Description: "Propose the full ordered plan for this task. Call ONCE, before any " +
|
||||
"execution, with EVERY step end-to-end (not one step at a time). FIRST step: " +
|
||||
"research (prior knowledge, relations, blast radius). If your plan runs `run` " +
|
||||
"against any target, include a LAST step: write back " +
|
||||
"(update_entity_attributes + create_relationship + upsert_knowledge) — if you " +
|
||||
"omit it, one is auto-appended. After this call: STOP and wait for operator " +
|
||||
"approval (approval vocabulary: approved, yes, go, proceed, continue, ok, " +
|
||||
"go ahead). Once a step has started (running/done/...), this tool REFUSES " +
|
||||
"further calls — advance with update_plan_step + run instead. Re-propose only " +
|
||||
"if the operator explicitly asks you to revise the whole plan. complete_task " +
|
||||
"with outcome=success is REFUSED if you ran `run` but didn't call " +
|
||||
"update_entity_attributes/create_relationship — write back before completing.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
@@ -177,7 +176,16 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
if err := a.store.setGoal(ctx, sessionID, goal); err != nil {
|
||||
return fmt.Sprintf("error setting goal: %v", err), true
|
||||
}
|
||||
return "Goal set: " + goal, true
|
||||
// P1: the plan window is NOT opened here. Opening it on set_goal
|
||||
// meant any config_mutation `run` auto-executed with zero operator
|
||||
// approval, before a plan was even proposed (let alone approved) —
|
||||
// a safety regression confirmed live in session d0d562e0. The
|
||||
// window is now opened only when the operator approves a plan
|
||||
// (chat-assent grant or explicit approval in agent.go), which is
|
||||
// what the SOUL.md "approve the plan, not each step" model actually
|
||||
// describes. set_goal records the goal + flips status to executing
|
||||
// and nothing more.
|
||||
return "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval.", true
|
||||
|
||||
case "propose_plan":
|
||||
raw, _ := args["steps"].([]any)
|
||||
@@ -198,11 +206,51 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
if len(steps) == 0 {
|
||||
return "error: propose_plan needs at least one step with a title", true
|
||||
}
|
||||
// D.2: auto-append a writeback step if the agent didn't include one.
|
||||
// The agent consistently writes vague last steps ("record findings")
|
||||
// and then skips update_entity_attributes entirely (the #1 cause of
|
||||
// knowledge-graph drift). Appending an explicit writeback step makes
|
||||
// the seq-order enforcement (5.6) require it to be completed last,
|
||||
// and D.1's complete_task gate enforces the actual calls. Together
|
||||
// they close the loop structurally — neither relies on the agent
|
||||
// reading SOUL.md.
|
||||
hasWritebackStep := false
|
||||
for _, st := range steps {
|
||||
if strings.Contains(st.Title, "update_entity_attributes") ||
|
||||
strings.Contains(st.Title, "create_relationship") ||
|
||||
strings.Contains(st.Detail, "update_entity_attributes") ||
|
||||
strings.Contains(st.Detail, "create_relationship") {
|
||||
hasWritebackStep = true
|
||||
break
|
||||
}
|
||||
}
|
||||
appendedNote := ""
|
||||
if !hasWritebackStep {
|
||||
steps = append(steps, planStepInput{
|
||||
Title: "Write back: update_entity_attributes + create_relationship + upsert_knowledge",
|
||||
Detail: "Call update_entity_attributes for every entity you ran against (versions, states, counts, timestamps). Call create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities (pass `about` as an array).",
|
||||
})
|
||||
appendedNote = fmt.Sprintf(" (appended a writeback step — your plan didn't include one; step %d)", len(steps))
|
||||
}
|
||||
persisted, err := a.store.proposePlan(ctx, sessionID, steps)
|
||||
if err != nil {
|
||||
if errors.Is(err, errPlanInFlight) {
|
||||
// The plan is already in flight — refuse the re-proposal.
|
||||
// The agent must advance the existing plan with
|
||||
// update_plan_step + run. This is the structural fix for
|
||||
// the "plan added twice" sidebar drift the operator
|
||||
// reported: instead of appending (which duplicated) or
|
||||
// wiping (which lost progress), we refuse and direct.
|
||||
return "Plan already in flight — refusing duplicate proposal. Steps exist and at least one has started (running/done/...). To advance: call update_plan_step(seq=K, status=\"running\") then run(...) for step K's target, then update_plan_step(seq=K, status=\"done\"). Do not call propose_plan again. Re-propose only if the operator explicitly asks you to revise the whole plan (the session is reopened on a follow-up — prior steps are marked `replaced` and a fresh generation is started), and say so in your reply before calling it.", true
|
||||
}
|
||||
return fmt.Sprintf("error proposing plan: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Plan set: %d step(s). Execute them now, marking each with update_plan_step as you go.", len(persisted)), true
|
||||
// The writeback step is now always present (D.2 auto-appends it if
|
||||
// the agent forgot), so the old advisory nudge is replaced by the
|
||||
// structural gate: D.1 refuses complete_task without the actual
|
||||
// update_entity_attributes/create_relationship calls.
|
||||
result := fmt.Sprintf("Plan set (%d steps)%s. If all steps are read-only, execute now — call update_plan_step(running) + run for each step, no approval needed. If any step is config_mutation/destructive, STOP and wait for operator approval (\"approved\", \"yes\", \"go\", \"proceed\", \"continue\", \"ok\", \"go ahead\"). Do not call propose_plan again.", len(persisted), appendedNote)
|
||||
return result, true
|
||||
|
||||
case "update_plan_step":
|
||||
seq := toInt(args["seq"])
|
||||
@@ -214,7 +262,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID); err != nil {
|
||||
return fmt.Sprintf("error updating step %d: %v", seq, err), true
|
||||
}
|
||||
return fmt.Sprintf("Step %d → %s", seq, status), true
|
||||
return fmt.Sprintf("Step %d → %s. (Advance with update_plan_step + run; do not re-propose.)", seq, status), true
|
||||
|
||||
case "ask_operator":
|
||||
prompt, _ := args["prompt"].(string)
|
||||
@@ -258,10 +306,28 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
"session", sessionID, "outcome", outcome)
|
||||
outcome = "partial"
|
||||
}
|
||||
// D.1: refuse success when discovery ran but no writeback followed.
|
||||
// The prior advisory warning (below) was ignorable — the agent
|
||||
// saw it and ended the task anyway. This gate fires BEFORE
|
||||
// completeTask runs, so the session stays in 'executing' state
|
||||
// and the agent must call update_entity_attributes/create_relationship
|
||||
// then retry complete_task. Only blocks `success`; an explicit
|
||||
// `failure` or `partial` is allowed through (the agent is
|
||||
// acknowledging it didn't finish — no reason to force writeback).
|
||||
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) && !a.store.hadEntityWriteback(ctx, sessionID) {
|
||||
return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true
|
||||
}
|
||||
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
||||
if errors.Is(err, errTaskAlreadyComplete) {
|
||||
return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true
|
||||
}
|
||||
return fmt.Sprintf("error completing task: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Task marked %s: %s", outcome, summary), true
|
||||
result := fmt.Sprintf("Task marked %s: %s", outcome, summary)
|
||||
if !a.store.hadEntityWriteback(ctx, sessionID) {
|
||||
result += "\n\n⚠️ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch."
|
||||
}
|
||||
return result, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
@@ -291,3 +357,60 @@ func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, response
|
||||
slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// autoCompleteIfPlanDone is the structural safety net for "the agent did the
|
||||
// work but forgot to call complete_task" — the #1 remaining model reliability
|
||||
// gap after D.1's writeback gate. After a turn ends, if the session has a goal,
|
||||
// the agent never called complete_task this turn, and either (a) all plan
|
||||
// steps are terminal OR (b) the agent did discovery (ran `run`), auto-complete.
|
||||
// Path (b) catches the common case where the agent skips update_plan_step
|
||||
// bookkeeping but still does the actual work — the D.1 gate already enforces
|
||||
// writeback before `complete_task`, so if the agent forgot to complete at all,
|
||||
// we close it out mechanically. If writeback happened → success; if not →
|
||||
// partial (honest: work was done but knowledge graph wasn't updated).
|
||||
func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseText string) {
|
||||
if a.store == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return
|
||||
}
|
||||
sess, err := a.store.getSession(ctx, sessionID)
|
||||
if err != nil || sess.Status != "executing" {
|
||||
return
|
||||
}
|
||||
// Don't auto-complete if there are pending approvals — the agent is
|
||||
// blocked waiting for the operator, not done. Auto-completing here
|
||||
// would close the session and the operator's approval would land on a
|
||||
// dead task. Confirmed in eval: agent hits P5 approval gate, turn
|
||||
// ends, auto-complete fires incorrectly because the approval-queue
|
||||
// `run` responses were logged as success=true in agent_activity.
|
||||
if a.store.hasPendingApprovals(ctx, sessionID) {
|
||||
return
|
||||
}
|
||||
discovery := a.store.hadDiscovery(ctx, sessionID)
|
||||
writeback := a.store.hadEntityWriteback(ctx, sessionID)
|
||||
// (a) all plan steps terminal, OR (b) agent did discovery (ran `run`).
|
||||
shouldComplete := a.store.allPlanStepsTerminal(ctx, sessionID)
|
||||
if !shouldComplete && discovery {
|
||||
shouldComplete = true
|
||||
}
|
||||
if !shouldComplete {
|
||||
return
|
||||
}
|
||||
outcome := "success"
|
||||
if discovery && !writeback {
|
||||
outcome = "partial" // honest: work done, knowledge graph not updated
|
||||
}
|
||||
summary := strings.TrimSpace(responseText)
|
||||
summary = strings.SplitN(summary, "\n", 2)[0]
|
||||
const maxLen = 120
|
||||
if len(summary) > maxLen {
|
||||
summary = summary[:maxLen] + "…"
|
||||
}
|
||||
if summary == "" {
|
||||
summary = "All plan steps completed."
|
||||
}
|
||||
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
||||
slog.Error("nomos: auto-complete plan-done task failed", "session", sessionID, "error", err)
|
||||
} else {
|
||||
slog.Info("nomos: auto-completed task — agent didn't call complete_task", "session", sessionID, "outcome", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ oikos.hubris.network {
|
||||
tls {
|
||||
dns ionos {env.IONOS_AUTH_API_TOKEN}
|
||||
}
|
||||
@enroll path /api/v1/clients/enroll
|
||||
@enroll path /api/v1/clients/enroll /oidc-callback
|
||||
handle @enroll {
|
||||
reverse_proxy 192.168.178.182:8090
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ FROM node:22-alpine AS builder
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY VERSION ./
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
1610
docs/mbse/README.md
Normal file
1610
docs/mbse/README.md
Normal file
File diff suppressed because it is too large
Load Diff
509
docs/mbse/components.md
Normal file
509
docs/mbse/components.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# Oikos — Component Views
|
||||
|
||||
> Companion to [the Model](README.md) and [the Framework](framework.md).
|
||||
> Where README.md's nine Views cut across the whole system by *concern*
|
||||
> (requirements, behavior, risk...), this document cuts across it by
|
||||
> *component* — one View per running part of the System, going one layer
|
||||
> deeper into its own internal structure than the whole-system Views do.
|
||||
> Per [framework.md](framework.md) §4, each section below is still a View
|
||||
> and must still answer Holt's three questions; they're stated once per
|
||||
> section rather than as a separate table, since here the Stakeholder is
|
||||
> almost always the same ("an engineer about to change this component")
|
||||
> and the Notation is the same (prose + Mermaid) throughout.
|
||||
|
||||
**How to use this alongside the other two documents:** if you're deciding
|
||||
*whether something belongs in the Model*, read [framework.md](framework.md).
|
||||
If you're asking *what does the system do and why*, read
|
||||
[README.md](README.md). If you're about to **change code in a specific
|
||||
package** and want to know its internal shape, its own state, and what's
|
||||
already known to be broken or dormant inside it before you touch it, read
|
||||
the relevant section here.
|
||||
|
||||
## Contents
|
||||
|
||||
| Component | Path | Status |
|
||||
|---|---|---|
|
||||
| [1. oikos api](#1-oikos-api) | `internal/httpapi`, `internal/mcp`, `internal/policy` | ✅ live — the decision/execution gate |
|
||||
| [2. oikos scheduler](#2-oikos-scheduler) | `internal/scheduler`, `internal/checkdefaults` | ✅ live — the observe loop |
|
||||
| [3. oikos notifier](#3-oikos-notifier) | `internal/notifier` | ✅ live — approval delivery |
|
||||
| [4. nomos](#4-nomos-agent-gateway) | `cmd/nomos` | ✅ live — the agent, unauthenticated gateway |
|
||||
| [5. web control room](#5-web-control-room) | `web/src` | ✅ live — standalone SPA |
|
||||
| [6. PostgreSQL/TimescaleDB](#6-postgresqltimescaledb) | `migrations/`, `seeds/` | ✅ live — the System's own source of truth |
|
||||
| [7. Dormant components](#7-dormant-components) | `internal/actuator`, `internal/learning` | 🔴 compiled, never started |
|
||||
| [8. Auxiliary components](#8-auxiliary-components) | `cmd/webhook`, `cmd/desktop` | ✅ live — deploy + packaging, not decision logic |
|
||||
|
||||
---
|
||||
|
||||
## 1. oikos api
|
||||
|
||||
**Stakeholders:** engineers extending the MCP tool surface, the `run` gate,
|
||||
or REST endpoints; anyone debugging why a specific command was or wasn't
|
||||
classified the way they expected. **Why this View earns its place:** this
|
||||
is the single component where the highest-consequence findings in
|
||||
[Risk & Safety](README.md#8-risk--safety) live — extending it without
|
||||
knowing its internal shape is how the kill-switch gap and the
|
||||
two-classifiers problem happened in the first place.
|
||||
|
||||
### oikos api — Internal structure
|
||||
|
||||
| File | Lines | Role |
|
||||
|---|---|---|
|
||||
| `internal/httpapi/server.go` | 842 | `NewHandler` (routing entry, L75), `combinedAuth` (L229), OIDC JWKS discovery/fetch/validate (L337-524), `GetActor` (L526), OIDC config/token/callback handlers (L575-712), `ListenAndServe` (L811) |
|
||||
| `internal/httpapi/impl.go` | 1,639 | Entity CRUD, lifecycle transitions + preconditions (per [ADR-0014](../adr/0014-entity-model.md)) |
|
||||
| `internal/httpapi/phase3.go` | 2,627 | Executions, approvals (`DecideApproval`), `sshExec`, `executeApprovedAction`, autonomy-settings read/write endpoints — the **largest single file in the component** |
|
||||
| `internal/httpapi/sse.go` | 366 | `LISTEN/NOTIFY` fan-out, ring-buffer replay |
|
||||
| `internal/httpapi/activity.go` | 215 | `agent_activity` read endpoints |
|
||||
| `internal/httpapi/knowledge.go` | 286 | Knowledge search/content endpoints |
|
||||
| `internal/httpapi/dashboard.go` | 172 | `dashboard/summary` |
|
||||
| `internal/httpapi/learning_view.go` | 129 | `learning/timeline`, `learning/trend` |
|
||||
| `internal/httpapi/problem.go` | 71 | RFC 9457 `problem+json` error envelope |
|
||||
| `internal/httpapi/default_checks.go` | 13 | Thin wrapper calling `internal/checkdefaults` on entity creation |
|
||||
| `internal/mcp/server.go` | 1,691 | All 33 MCP tool registrations (`get_entity` at L76 through `list_my_secrets` at L753), `sshExec` (L1031), `resolveExecTarget` (L1223), **`classifyAndGate`** (L1264-1417) |
|
||||
| `internal/policy/command.go` | 174 | `ClassifyCommand` (L108) — the **live** classifier, `computeCommandRisk` (L127), `allSegmentsReadOnly` (L157), `riskRank` (L26) |
|
||||
| `internal/policy/classify.go` | 157 | `ClassifySignal` (L46) — **dead code, zero callers** (see [Roadmap §9.2](README.md#92-code-real--dead-code--schema-only-matrix)) |
|
||||
|
||||
### oikos api — internal call structure: the `run` gate, by file
|
||||
|
||||
README.md's [§3.3](README.md#3-functional-architecture) shows the *decision
|
||||
logic* of the `run` gate. This shows the *code path* — which file hands off
|
||||
to which — because they're not the same question: the decision flowchart
|
||||
tells you what happens, this tells you where to go fix it.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
MCP["mcp/server.go\nrun tool handler, L366"] --> GATE["mcp/server.go\nclassifyAndGate, L1264"]
|
||||
GATE --> RESOLVE["mcp/server.go\nresolveExecTarget, L1223"]
|
||||
GATE --> CLASSIFY["policy/command.go\nClassifyCommand, L108"]
|
||||
CLASSIFY --> RISK["policy/command.go\ncomputeCommandRisk, L127\nallSegmentsReadOnly, L157"]
|
||||
GATE -->|read_only or window open| EXEC["mcp/server.go\nsshExec, L1031"]
|
||||
GATE -->|otherwise| APPROVAL["phase3.go\ncreateApproval"]
|
||||
APPROVAL -->|operator decides| DECIDE["phase3.go\nDecideApproval"]
|
||||
DECIDE --> EXEC2["phase3.go\nsshExec\n(separate implementation)"]
|
||||
|
||||
style CLASSIFY fill:#e8f5e9,stroke:#2e7d32
|
||||
style EXEC fill:#fff3e0,stroke:#e65100
|
||||
style EXEC2 fill:#fff3e0,stroke:#e65100
|
||||
```
|
||||
|
||||
The two orange boxes are the same finding stated visually: `mcp/server.go`
|
||||
and `phase3.go` each have **their own `sshExec`**, independently written,
|
||||
not sharing an implementation. Fix one path's SSH handling and the other is
|
||||
untouched — verified during the Roadmap audit, not assumed.
|
||||
|
||||
### oikos api — Interfaces this component owns
|
||||
|
||||
Full catalogs live in [README.md §5](README.md#5-interfaces-icd) (33 MCP
|
||||
tools, REST groups, SSE event types) — not repeated here. What's specific
|
||||
to *this* component's internal ownership: `internal/mcp/server.go` owns
|
||||
every MCP tool; `internal/httpapi/{impl,phase3,sse,activity,knowledge,
|
||||
dashboard,learning_view}.go` own the REST surface between them, split by
|
||||
resource area rather than by file size; `internal/policy/command.go` is a
|
||||
pure function library with no HTTP surface of its own, called only from
|
||||
`classifyAndGate`.
|
||||
|
||||
### oikos api — Status and known issues
|
||||
|
||||
All of the following are detailed with evidence in
|
||||
[Roadmap & Traceability](README.md#9-roadmap--traceability) and
|
||||
[Risk & Safety](README.md#8-risk--safety) — cross-referenced here so an
|
||||
engineer opening this specific package sees them before making a change,
|
||||
not after:
|
||||
|
||||
- `policy.ClassifySignal` (in this component) is dead code; the schema it
|
||||
reads (`autonomy_settings.global.auto_act`, `never_auto_act.*`) is
|
||||
therefore not enforced by anything in the live request path — [§8.1](README.md#81-the-kill-switch-gap-verified-most-important-finding-in-this-model).
|
||||
- `notifier.VerifyApprovalToken` (a different component, §3 below) is dead;
|
||||
`phase3.go:DecideApproval` reimplements token verification inline instead
|
||||
of calling it.
|
||||
- `domain.Execution`'s state constants are descriptive only — `phase3.go`
|
||||
writes ad-hoc SQL string statuses that don't map 1:1 onto them.
|
||||
- SSH host key verification is disabled (`InsecureIgnoreHostKey`) on the
|
||||
actuation path — open gap B4.
|
||||
|
||||
---
|
||||
|
||||
## 2. oikos scheduler
|
||||
|
||||
**Stakeholders:** engineers adding a new probe kind or debugging why a
|
||||
signal did or didn't fire. **Why this View earns its place:** the
|
||||
scheduler is the only component that runs unattended on a fixed interval
|
||||
with no operator or agent triggering it — its failure modes look different
|
||||
from every request-driven component above.
|
||||
|
||||
### oikos scheduler — Internal structure
|
||||
|
||||
| File | Lines | Role |
|
||||
|---|---|---|
|
||||
| `internal/scheduler/scheduler.go` | 761 | Everything — no sub-packages |
|
||||
| `internal/scheduler/init.go` | 13 | `RunnerForMain()` — the only thing `cmd/oikos`'s `scheduler` role calls |
|
||||
| `internal/checkdefaults/defaults.go` | — | `ForEntityType` (L60-123), `Ensure` (L144-204), `DefaultInterval` (L133-142) — default check provisioning on entity creation |
|
||||
|
||||
Key functions inside `scheduler.go`: `Run` (L36-64, the tick loop, default
|
||||
30s), `runCheckPass` (L67-94, loads `check_defs`, dispatches with a
|
||||
10-worker `errgroup` limit), `runCheck` (L104-186), `executeCheck` (L237-254,
|
||||
the kind dispatcher), `resolveSignal` (L206-225), `evaluateSeverity`
|
||||
(L737-760), `staleSweep` (L286-333, 3× fastest interval / 5 min floor).
|
||||
|
||||
### oikos scheduler — behavior specific to this component: the probe dispatch
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
TICK["Run tick, every 30s"] --> LOAD["ListEnabledCheckDefs"]
|
||||
LOAD --> DISPATCH["executeCheck: dispatch by kind"]
|
||||
DISPATCH --> HTTP["checkHTTP, L336"]
|
||||
DISPATCH --> TCP["checkTCP, L393"]
|
||||
DISPATCH --> DISK["checkDisk, L424"]
|
||||
DISPATCH --> CERT["checkCertExpiry, L474"]
|
||||
DISPATCH --> PING["checkPing, L545"]
|
||||
DISPATCH --> SSH["checkSSHScript, L613"]
|
||||
HTTP & TCP & DISK & CERT & PING & SSH --> RESULT["checkResult struct\nhealth, signalKind, evidence, metrics"]
|
||||
RESULT -->|healthy| RESOLVE["resolveSignal\nraw SQL, bypasses Signal.CanTransition"]
|
||||
RESULT -->|unhealthy| UPSERT["UpsertSignal\ndedup by target+kind"]
|
||||
RESULT --> METRICS["INSERT metric_samples"]
|
||||
RESULT --> STATUS["UpsertEntityStatus"]
|
||||
```
|
||||
|
||||
`checkSSHScript` (L613-724) is the odd one out: it shells out to the system
|
||||
`ssh` binary directly (`BatchMode=yes`, `StrictHostKeyChecking=no`) rather
|
||||
than using a Go SSH library, restricted to scripts matching
|
||||
`^[a-z][a-z0-9_-]+\.sh$` at a fixed path `/opt/oikos/checks/<script>`. The
|
||||
18 scripts it can run (`cpu_check.sh`, `disk_usage_check.sh`,
|
||||
`docker_health_check.sh`, `zfs_check.sh`, …) live in `checks/` in this repo
|
||||
and are auto-deployed to every enrolled client by `tools/setup-checks.sh`
|
||||
(per AGENTS.md §8) — this component's actual probe logic is split between
|
||||
Go code here and shell scripts version-controlled elsewhere in the repo.
|
||||
|
||||
### oikos scheduler — Interfaces this component owns
|
||||
|
||||
No external API — this is the one component with no inbound interface at
|
||||
all, only outbound: SSH to the fleet (probes), and writes to
|
||||
`metric_samples`/`signals`/`entity_status`/`events` that every other
|
||||
component reads. It is a pure producer.
|
||||
|
||||
### oikos scheduler — Status and known issues
|
||||
|
||||
- Never calls `policy.ClassifySignal` — signals it raises sit as
|
||||
`state='raised'` with no automatic classification; whatever consumes
|
||||
them downstream (the agent, the console) does its own interpretation.
|
||||
- `resolveSignal` updates `raised → resolved` via raw SQL, bypassing the
|
||||
one enforced state machine in the domain layer
|
||||
(`domain.Signal.CanTransition`) — the specific transition happens to be
|
||||
legal today, but nothing would stop a future change from making it not.
|
||||
|
||||
---
|
||||
|
||||
## 3. oikos notifier
|
||||
|
||||
**Stakeholders:** engineers debugging a missed or duplicate Matrix alert,
|
||||
or extending the approval-delivery mechanism to a new channel.
|
||||
**Why this View earns its place:** this is the one component whose entire
|
||||
job is bridging an asynchronous human decision into the same-shaped
|
||||
synchronous decision every other component expects — worth understanding
|
||||
in isolation before assuming "approval" means one simple thing.
|
||||
|
||||
### oikos notifier — Internal structure
|
||||
|
||||
All in `internal/notifier/notifier.go` (305 lines, one file, no
|
||||
sub-packages): `Run` (L25-47, two tickers — 15s for pending approvals, 30s
|
||||
for reaction polling), `processPendingApprovals` (L65-115, generates the
|
||||
token/hash lazily on first pass), `generateApprovalToken` (L275-283,
|
||||
HMAC-SHA256 over approval ID + nanosecond timestamp), `hashToken`
|
||||
(L302-305, only the hash is stored), `sendMatrixAlert` (L231-272),
|
||||
`pollReactions`/`checkReaction` (L118-201), `callDecideApproval`
|
||||
(L204-228), `VerifyApprovalToken` (L286-300, **dead code**).
|
||||
|
||||
### oikos notifier — Behavior specific to this component
|
||||
|
||||
The full sequence (Matrix + console paths converging on one decision
|
||||
endpoint) is in [README.md §6.4](README.md#64-sequence--the-run-primitive-end-to-end).
|
||||
Specific to this component in isolation: it never calls into
|
||||
`internal/httpapi` directly except through one HTTP call
|
||||
(`callDecideApproval`, an ordinary client request to
|
||||
`POST /api/v1/approvals/{id}/decision`) — the notifier and the API process
|
||||
communicate **only through the database and one HTTP endpoint**, never
|
||||
through shared Go state, which is why the header comment in `notifier.go`
|
||||
calls this a "DB rendezvous pattern."
|
||||
|
||||
### oikos notifier — Interfaces this component owns
|
||||
|
||||
Outbound only: the Matrix client-server API
|
||||
(`PUT /rooms/.../send/m.room.message`, `GET /relations/.../m.annotation`)
|
||||
and one outbound call to the API's own approval-decision endpoint. No
|
||||
inbound interface — nothing calls into the notifier process.
|
||||
|
||||
### oikos notifier — Status and known issues
|
||||
|
||||
- `VerifyApprovalToken` is dead code; `phase3.go:DecideApproval` (a
|
||||
different component, §1 above) reimplements the same hash-compare logic
|
||||
inline rather than calling it — a single source of truth for token
|
||||
verification does not currently exist.
|
||||
- Open gap A2: `alert_sent_at` is written *after* the send attempt, so a
|
||||
failed UPDATE re-sends the alert on the next poll; no dedup beyond that,
|
||||
and reaction-polling API calls are unbounded.
|
||||
|
||||
---
|
||||
|
||||
## 4. nomos (agent gateway)
|
||||
|
||||
**Stakeholders:** engineers changing agent behavior, adding a task tool, or
|
||||
investigating a stuck/duplicated task. **Why this View earns its place:**
|
||||
this is the largest component by line count (4,681 lines across six files)
|
||||
and the one with the most active recent bug-fix history
|
||||
(`plans/2026-07-11-nomos-agent-code-review.md`,
|
||||
`plans/done/2026-07-14-post-fix-session-remainders.md`) — its internal
|
||||
shape is not obvious from outside.
|
||||
|
||||
### nomos — Internal structure
|
||||
|
||||
| File | Lines | Role |
|
||||
|---|---|---|
|
||||
| `cmd/nomos/main.go` | 914 | Gateway HTTP server (`:8092`), `/query`/`/chat`/`/sessions` routes, the hand-rolled Streamable-HTTP MCP client (`mcpClient`, per-session pooled) |
|
||||
| `cmd/nomos/store.go` | 1,472 | Persistence — sessions, messages, `logActivity` |
|
||||
| `cmd/nomos/agent.go` | 861 | The agentic loop itself; model config (L62-120); `maxIterations = 40` (L24, a **hard-coded constant**, not read from `nomos/config.yaml`'s `max_iterations: 15` — the two disagree, see status below) |
|
||||
| `cmd/nomos/tasks.go` | 416 | The five nomos-local task tools: `set_goal`, `propose_plan`, `update_plan_step`, `ask_operator`, `complete_task` — handled in-process, never forwarded to `internal/mcp` |
|
||||
| `cmd/nomos/continue.go` | 347 | The auto-continuation worker — polls `nomos_plan_executions` |
|
||||
| `cmd/nomos/assent.go` | 183 | `isAssent`/`isTypedConfirmation` — regex word-boundary matching (fixed 2026-07-11 after a false-positive bug where "yesterday" matched "yes") |
|
||||
|
||||
### nomos — Behavior specific to this component
|
||||
|
||||
The Task lifecycle and auto-continuation sequences are in
|
||||
[README.md §3.4](README.md#34-functional-flow--the-task-lifecycle-f3f4f5-packaged-for-a-human)
|
||||
and [§6.5](README.md#65-sequence--plan-auto-continuation-the-system-is-the-event-loop).
|
||||
Specific to this component: the LLM sees a **union of two tool sources** —
|
||||
the 33 tools fetched live from `api`'s `/mcp` endpoint via `tools/list`,
|
||||
plus the 5 local task tools in `tasks.go` — and `agent.go`'s per-call
|
||||
routing decides in-process versus forwarded with no visible seam to the
|
||||
model itself. A hidden `_session_id` is injected into forwarded calls on
|
||||
the wire (never in the model-visible arguments) so `internal/mcp/server.go`
|
||||
can scope assent/destructive windows per task.
|
||||
|
||||
### nomos — Interfaces this component owns
|
||||
|
||||
| Route | Auth |
|
||||
|---|---|
|
||||
| `GET /healthz` | none |
|
||||
| `POST /query` (structured tool call or a pointer to `/chat`) | **none** |
|
||||
| `POST /chat` (SSE, the real agentic loop) | **none** |
|
||||
| `GET/POST /sessions`, `/sessions/{id}` | **none** |
|
||||
|
||||
This entire interface is unauthenticated — full detail in
|
||||
[README.md §5.4](README.md#54-nomos-http-interface-cmdnomos-port-8092).
|
||||
Outbound: a pooled MCP client to `api`, and chat completions to OpenRouter
|
||||
(`data_collection: deny` pinned, default model `deepseek/deepseek-v4-pro`).
|
||||
|
||||
### nomos — Status and known issues
|
||||
|
||||
- **C1, the most consequential open gap involving this component**: zero
|
||||
authentication on the entire gateway, including the ability to grant
|
||||
chat-assent approvals with no credential check. Explicitly deferred by
|
||||
operator instruction, not an oversight — see
|
||||
[README.md §8.4](README.md#84-known-open-security-gaps).
|
||||
- `nomos/config.yaml`'s `max_iterations: 15` does not match the enforced
|
||||
Go constant (`40`) — one of the two is stale.
|
||||
- Dual `agent_activity` logging: both this component's `store.logActivity`
|
||||
and `internal/mcp/server.go`'s `withActivityLogging` (a different
|
||||
component) log the same forwarded tool call. Not confirmed whether this
|
||||
is an intentional two-sided audit trail or accidental duplication.
|
||||
|
||||
---
|
||||
|
||||
## 5. web control room
|
||||
|
||||
**Stakeholders:** the operator, directly; engineers changing the UI's data
|
||||
model or adding a page. **Why this View earns its place:** this is the only
|
||||
component with no server-side logic of its own — understanding it means
|
||||
understanding what it *doesn't* do (it is not the system of record for
|
||||
anything) as much as what it does.
|
||||
|
||||
### web control room — Internal structure
|
||||
|
||||
Nine pages under `web/src/pages/` (Svelte 5, hash-based routing, no router
|
||||
library):
|
||||
|
||||
| Page | Lines | Shows |
|
||||
|---|---|---|
|
||||
| `Overview.svelte` | 262 | Task dashboard — fleet/health/signal summary cards, the task list, entry point for launching a new chat |
|
||||
| `Chat.svelte` | 410 | The conversation UI — streaming, `TaskContextPanel`, tool-call rendering, inline approvals |
|
||||
| `Ops.svelte` | 240 | Approvals queue, recent activity/execution feed, approve/deny/cancel |
|
||||
| `Signals.svelte` | 171 | Alert/signal triage — severity filter, ack/resolve/mute |
|
||||
| `KnowledgeBase.svelte` | 263 | Entity browser — force-directed graph view and table view |
|
||||
| `Knowledge.svelte` | 186 | Free-text knowledge search |
|
||||
| `Learning.svelte` | 174 | Pattern/skill telemetry — the one page whose backing data source
|
||||
(`internal/learning`) is dormant per §7 below, so this page currently shows
|
||||
whatever accumulated before the engine stopped being called, not a live
|
||||
feed |
|
||||
| `Config.svelte` | 202 | Auth/connection screen — static bearer token or OIDC login |
|
||||
| `EntityDetail.svelte` | 7 | Thin wrapper, deep-link target |
|
||||
|
||||
Shared logic under `web/src/lib/`: `config.ts` (`fetchWithAuth`, the single
|
||||
wrapper every API call goes through), `oidc.ts`, `api.ts`, `tasks.ts`,
|
||||
`stores/events.ts` (the always-on SSE connection), plus task-specific
|
||||
components (`TaskContextPanel.svelte`, `GoalHeader.svelte`,
|
||||
`PlanProgress.svelte`, `OperatorQuestion.svelte`, `SessionGraph.svelte`).
|
||||
|
||||
### web control room — Behavior specific to this component
|
||||
|
||||
Two data-flow patterns, not one: most pages fetch REST on mount and
|
||||
re-fetch on a relevant SSE event; `Chat.svelte`'s `TaskContextPanel` is
|
||||
driven by the **always-on global event stream**
|
||||
(`web/src/lib/stores/events.ts`), not the per-turn chat SSE connection —
|
||||
deliberately, so the live context panel stays populated during
|
||||
server-side auto-continuation (§4 above) when no chat turn is actually
|
||||
open, and survives a tab reload.
|
||||
|
||||
### web control room — Interfaces this component owns
|
||||
|
||||
None inbound — it is a pure consumer of `internal/httpapi`'s REST and SSE
|
||||
interfaces (full catalog: [README.md §5](README.md#5-interfaces-icd)).
|
||||
`fetchWithAuth` resolves config per request rather than at import time, so
|
||||
the same build works same-origin (production, Vite dev proxy) or
|
||||
cross-origin (the Wails desktop webview, §8).
|
||||
|
||||
### web control room — Status and known issues
|
||||
|
||||
Standalone deploy, versioned and released independently of the `oikos`
|
||||
binary — see [README.md §4.5](README.md#45-build--release-artifacts) for
|
||||
why "deployed" means two different release cadences depending on whether
|
||||
you mean the container or the desktop app.
|
||||
|
||||
---
|
||||
|
||||
## 6. PostgreSQL/TimescaleDB
|
||||
|
||||
**Stakeholders:** anyone writing a migration, or reasoning about what
|
||||
"the system's source of truth" actually means (see
|
||||
[framework.md §2](framework.md#2-the-goal--system-and-model-made-concrete)
|
||||
for why that phrase needs disambiguating from the engineering Model).
|
||||
**Why this View earns its place:** every other component in this document
|
||||
either reads from or writes to this one; it is the only component every
|
||||
other component has in common.
|
||||
|
||||
### PostgreSQL/TimescaleDB — Internal structure
|
||||
|
||||
20 forward-only, idempotent migrations
|
||||
(`001_ontology.up.sql` … `020_session_reliability.up.sql`,
|
||||
[ADR-0008](../adr/0008-forward-only-migrations.md)). Four TimescaleDB
|
||||
hypertables, all created in `006_observability.up.sql`:
|
||||
`metric_samples`, `audit_log`, `events`, `agent_activity` — each with
|
||||
continuous aggregates and retention policies.
|
||||
|
||||
The recurring structural pattern across this schema, per
|
||||
[ADR-0014](../adr/0014-entity-model.md) §7: **dual entities** —
|
||||
`check_defs`, `signals`, `classifications`, `executions`, `feedback`,
|
||||
`patterns`, `skills`, `approvals`, `knowledge_entities`, and (as of
|
||||
migration 018) `agent_sessions`-as-`task` all have an
|
||||
`entity_id UUID PK REFERENCES entities(id)`, meaning every specialized row
|
||||
is simultaneously a node in the general entity graph — this is what lets
|
||||
`get_relations`/`get_blast_radius` work uniformly over signals, tasks, and
|
||||
infrastructure alike without a special case for each.
|
||||
|
||||
Partial unique indexes provide snapshot semantics without an application-
|
||||
level lock: `relationships` (current edges only), `signals` (one open
|
||||
signal per entity+kind), `patterns` (one per type+action).
|
||||
|
||||
### PostgreSQL/TimescaleDB — Behavior specific to this component
|
||||
|
||||
`008_event_notify.up.sql`'s `pg_notify` trigger on `events` INSERT is the
|
||||
entire mechanism behind [README.md's SSE interface](README.md#55-server-sent-events-internalhttpapissego)
|
||||
— the database, not the API process, is what decides an event happened;
|
||||
the API process is just a fan-out listener.
|
||||
|
||||
### PostgreSQL/TimescaleDB — Interfaces this component owns
|
||||
|
||||
Every Go component in this document connects directly (via `sqlc`-generated
|
||||
queries, `internal/db`) — there is no ORM abstraction layer and no
|
||||
component-specific access restriction beyond what each service's own
|
||||
Postgres role grants (notably: the learning engine's DB role has no grants
|
||||
on governance/autonomy tables, per
|
||||
[ADR-0006](../adr/0006-learning-proposal-only.md) — a structural guarantee
|
||||
that would matter the moment §7's dormant learning engine is wired back in).
|
||||
`seeds/*.yaml` + `oikos seed`/`oikos export` form the bootstrap/DR
|
||||
interface — the database can be regenerated from seeds, and seeds can be
|
||||
regenerated from the database.
|
||||
|
||||
### PostgreSQL/TimescaleDB — Status and known issues
|
||||
|
||||
A shared-Postgres single point of failure across every service is a
|
||||
documented residual risk in [ADR-0007](../adr/0007-threat-model.md), not a
|
||||
newly discovered one.
|
||||
|
||||
---
|
||||
|
||||
## 7. Dormant components
|
||||
|
||||
**Stakeholders:** anyone deciding whether to revive auto-act, or tempted to
|
||||
extend `internal/actuator`/`internal/learning` believing them to be the
|
||||
live implementation. **Why this View earns its place:** these are the two
|
||||
components most likely to mislead an engineer navigating by package name —
|
||||
both are substantial, well-written, and compile cleanly into the `oikos`
|
||||
binary, and neither runs.
|
||||
|
||||
### Dormant components — Internal structure
|
||||
|
||||
| File | Lines | Role, and why it's dormant |
|
||||
|---|---|---|
|
||||
| `internal/actuator/actuator.go` | 505 | `Run` (L24-41, 10s ticker), `processAutoActSignals`, kill-switch checks (L47, L69 — `getAutonomySetting` for `global.auto_act` and `never_auto_act.<slug>`), a real circuit breaker (L156-202, threshold + cooldown), advisory locking (L87-99) — **but the executor itself is a hardcoded stub**, `{"success": true, "message": "stub execution"}` (L124-137), and `Run()` is never called by `cmd/oikos/main.go` or any `docker-compose.yml` service |
|
||||
| `internal/actuator/ssh.go` | 291 | `ExecuteProcedure` (L126-230) — a fully-built step-by-step SSH runner with `classifySSHError` (L77-108: network/auth/timeout/remote), per-step timeouts, verify-step semantics. **Zero callers anywhere in the codebase.** |
|
||||
| `internal/learning/learning.go` | 183 | `Run` (L20-41, hourly ticker), `extractPatterns` (L45-81), `processGroup` (L83-169, Wilson lower-bound confidence, evidence≥5 ∧ confidence≥0.7 → `validated`, anomaly quarantine at >10 same-key events per pass) — algorithmically faithful to [ADR-0006](../adr/0006-learning-proposal-only.md), **never started by any process** |
|
||||
|
||||
### Why this matters more than "unused code"
|
||||
|
||||
`internal/actuator/actuator.go` is where the policy kill-switch
|
||||
(`global.auto_act`, `never_auto_act.*`) is actually checked in Go — the
|
||||
*only* other place is the dead `policy.ClassifySignal`. Reviving auto-act
|
||||
and fixing the kill-switch gap
|
||||
([README.md §8.1](README.md#81-the-kill-switch-gap-verified-most-important-finding-in-this-model))
|
||||
are, structurally, **the same piece of work** — whoever picks up
|
||||
`internal/actuator/actuator.go:47-69` is the person who also resolves
|
||||
REQ-DEC-5. This is stated explicitly here because it is not obvious from
|
||||
reading the Risk & Safety or Roadmap views in isolation; it only becomes
|
||||
visible once you've read this component's code.
|
||||
|
||||
### Dormant components — Status and known issues
|
||||
|
||||
This entire section *is* the known issue — see
|
||||
[README.md §9.1](README.md#91-the-north-star-general-gated-execution)
|
||||
("revive auto-act," the one item the `general-gated-execution` plan's own
|
||||
header still marks open) and
|
||||
[§9.4](README.md#94-suggested-next-steps-informational--not-a-commitment-not-a-plan)
|
||||
item 1 and 3 for the two decisions this leaves open: wire these back in, or
|
||||
delete them and correct the "Phase 3 — DONE" claim in
|
||||
`OIKOS.md`/ADR-0014 that currently overstates what's running.
|
||||
|
||||
---
|
||||
|
||||
## 8. Auxiliary components
|
||||
|
||||
Two small, single-purpose components that support deployment and
|
||||
packaging rather than decision logic — given lighter treatment here
|
||||
deliberately, since no Stakeholder identified in this document's other
|
||||
sections needs their internals to make a change elsewhere.
|
||||
|
||||
**`cmd/webhook`** (96 lines, one file) — the Gitea push-to-deploy receiver.
|
||||
Verifies `X-Hub-Signature-256` HMAC-SHA256 against `WEBHOOK_HMAC_SECRET`
|
||||
using `hmac.Equal`, responds `202` immediately, then runs
|
||||
`scripts/deploy.sh` asynchronously. Full deploy sequence:
|
||||
[README.md §4.2](README.md#42-deployment-topology-mac-mini-docker-compose)
|
||||
and §7.2.
|
||||
|
||||
**`cmd/desktop`** (784 lines) — the Wails-wrapped desktop shell. Bundles
|
||||
`web/dist` into a native binary, adds an OIDC login flow through a local
|
||||
HTTP server + system browser, and a `SaveConfig` bridge the web bundle
|
||||
calls when running inside the desktop webview (`web/src/lib/config.ts`'s
|
||||
`saveToDesktop()`). Contains no decision logic of its own — it is
|
||||
packaging for component 5 (§5 above), not a new component in the
|
||||
functional sense.
|
||||
|
||||
---
|
||||
|
||||
## Keeping this document current
|
||||
|
||||
The same discipline as README.md's closing note applies here, scoped to
|
||||
components: when a file listed in a "Internal structure" table is renamed,
|
||||
split, or gains a new responsibility, update that row. When a "Status and
|
||||
known issues" bullet is resolved, remove it — and check whether removing it
|
||||
also resolves an entry in
|
||||
[README.md §9](README.md#9-roadmap--traceability), since most of the
|
||||
findings here were first surfaced there and are repeated in this document
|
||||
for proximity to the code, not because they're independently tracked in
|
||||
two places.
|
||||
414
docs/mbse/framework.md
Normal file
414
docs/mbse/framework.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# Oikos — MBSE Framework, Ontology & Viewpoints
|
||||
|
||||
> Companion to [the system Model](README.md). Where `README.md` **is** the
|
||||
> Model — the populated Views — this document is the **Framework**: the
|
||||
> template those Views were built from. It follows Jon Holt, *Systems
|
||||
> Engineering Demystified* (2nd ed., 2023), Ch. 2, "Model-Based Systems
|
||||
> Engineering," almost to the letter — the terms below (Model, View,
|
||||
> Viewpoint, Notation, Ontology, Framework, Process Set, Compliance) are
|
||||
> Holt's, not a paraphrase, because the whole point of adopting an Ontology
|
||||
> is to stop each document inventing its own vocabulary.
|
||||
|
||||
> **Holt's core claim, stated once so it doesn't need restating per
|
||||
> section:** *"When the Ontology and the Viewpoints are put together, they
|
||||
> form what is known as a Framework. A Framework is created as a template,
|
||||
> or blueprint, for a complete Model."* (Ch. 2, p. 40). Ontology is, in
|
||||
> Holt's words, "arguably the single most important part of MBSE as all of
|
||||
> the other elements that make up MBSE are ultimately traceable back to the
|
||||
> Ontology" (p. 40).
|
||||
|
||||
## 1. MBSE in a Slide — applied to Oikos
|
||||
|
||||
Holt's book converges the whole chapter into one diagram known across the
|
||||
Systems Engineering community as "MBSE in a slide" (Holt & Perry, 2019),
|
||||
extended with Implementation and Compliance. Below is that same structure
|
||||
with every box filled in for this specific repository, not left generic.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph APPROACH["APPROACH \n what must be produced, and how"]
|
||||
FW["Framework \n Ontology + Viewpoints \n = this document"]
|
||||
PS["Process Set \n ADRs, plans, this repo's\nreview/CI conventions"]
|
||||
end
|
||||
|
||||
subgraph GOAL["GOAL \n why any of this exists"]
|
||||
SYS["System \n the hubris homelab,\ngoverned by Oikos"]
|
||||
MDL["Model \n README.md \n the nine Views"]
|
||||
end
|
||||
|
||||
subgraph VIS["VISUALIZATION \n how it is communicated"]
|
||||
NOT["Notation \n Markdown + Mermaid"]
|
||||
DIA["Diagrams \n flowchart, stateDiagram,\nsequenceDiagram, classDiagram"]
|
||||
end
|
||||
|
||||
subgraph IMPL["IMPLEMENTATION"]
|
||||
TOOL["Tools \n git, a Markdown renderer,\nMermaid; no dedicated\nSysML tool"]
|
||||
end
|
||||
|
||||
subgraph COMP["COMPLIANCE"]
|
||||
BP["Best Practice \n ISO 42010 viewpoint and view\nterminology, informally aligned,\nnot certified"]
|
||||
end
|
||||
|
||||
FW --> MDL
|
||||
PS --> MDL
|
||||
MDL --> SYS
|
||||
NOT --> DIA
|
||||
DIA --> MDL
|
||||
TOOL --> NOT
|
||||
TOOL --> FW
|
||||
BP --> PS
|
||||
BP --> FW
|
||||
```
|
||||
|
||||
| Holt's concept | Generic definition (Ch. 2) | Oikos instantiation |
|
||||
|---|---|---|
|
||||
| **System** | The thing Systems Engineering exists to develop | The **hubris homelab** — hosts, LXCs, VMs, services, network — *and* the Oikos control plane that governs it. See note below on the reflexive boundary. |
|
||||
| **Model** | The abstraction of the System; the single source of truth for engineering knowledge about it | [README.md](README.md) — the nine-View system model |
|
||||
| **View** | A validated collection of information within the Model | Each of README.md's nine numbered sections |
|
||||
| **Viewpoint** | The template for a View — the stored answers to *which Stakeholders, why, what information* | §4 of this document — the Viewpoint catalog |
|
||||
| **Ontology** | The domain-specific language every Viewpoint's content is expressed in | §3 of this document, and — distinctively for this system — literally implemented in code as `seeds/ontology.yaml` |
|
||||
| **Notation** | The spoken/visual language used to communicate a View | Markdown prose + Mermaid diagrams (flowchart, stateDiagram-v2, sequenceDiagram, classDiagram) |
|
||||
| **Diagram** | One rendering of a View through a Notation's lens | Each Mermaid block in README.md |
|
||||
| **Framework** | Ontology + Viewpoints, together | This document |
|
||||
| **Process Set** | The steps for developing and using the Framework — the "how" | This repo's ADR process ([docs/adr/](../adr/)), design-doc process (`plans/`), and the research-then-write method used to build README.md (see its own header note on verified vs. per-research-pass findings) |
|
||||
| **Tool** | What implements the Notation and the Framework | Git + a Markdown/Mermaid renderer for the Notation; no dedicated MBSE tool enforces the Framework — see §6 for the honest gap this leaves |
|
||||
| **Compliance** | Demonstrating the approach meets external best practice | §5 |
|
||||
|
||||
**On the System's boundary being reflexive.** Most MBSE textbook examples
|
||||
model a System that is wholly separate from the engineering process
|
||||
describing it (a car, a radar). Oikos is not: the System being modeled
|
||||
*is* an autonomous control system, and the Model describing it (this
|
||||
documentation) sits outside a boundary that the System itself polices with
|
||||
its own internal "model" — the Postgres database, which
|
||||
[ADR-0003](../adr/0003-db-native-ontology-yaml-seeds.md) calls the runtime
|
||||
single source of truth. These are two different, non-competing uses of
|
||||
"model": the **engineering Model** (this doc set) is Holt's sense — a
|
||||
human-facing abstraction for realizing the System successfully. The
|
||||
**runtime database** is an operational sense — the System's own record of
|
||||
its current state, which the engineering Model *describes* but does not
|
||||
*replace*. Conflating the two would suggest this documentation is
|
||||
authoritative over live state, which it explicitly is not — README.md's
|
||||
own verification discipline (verified vs. per-research-pass) exists
|
||||
precisely because the engineering Model can drift from what the database
|
||||
and code actually do.
|
||||
|
||||
## 2. The Goal — System and Model, made concrete
|
||||
|
||||
**The System**, enumerated (this is "taking all the components of the
|
||||
system," per Holt's instruction that a valid View must be traceable to
|
||||
real Stakeholders and real information — not an abstract diagram):
|
||||
|
||||
| Component | Role |
|
||||
|---|---|
|
||||
| `oikos api` (`internal/httpapi`, `internal/mcp`) | REST + MCP server, the decision/execution gate |
|
||||
| `oikos scheduler` (`internal/scheduler`) | Observe loop — probes, signals |
|
||||
| `oikos notifier` (`internal/notifier`) | Approval delivery — Matrix, token issuance |
|
||||
| `nomos` (`cmd/nomos`) | The AI agent — MCP client, task/plan orchestration |
|
||||
| `web` (Svelte 5 SPA) | Control-room UI |
|
||||
| PostgreSQL/TimescaleDB | The System's own runtime source of truth |
|
||||
| The managed fleet | Hosts, LXCs, VMs, services under Oikos's governance |
|
||||
| `internal/actuator`, `internal/learning` | Compiled into the System, **not** currently part of its running behavior — see README.md §9.2 |
|
||||
|
||||
**The Model** is [README.md](README.md) in full: nine Views (Mission,
|
||||
Requirements, Functional Architecture, Physical Architecture, Interfaces,
|
||||
Behavior, Verification & Validation, Risk & Safety, Roadmap &
|
||||
Traceability). Per Holt's consistency test (p. 35): *"If there is a set of
|
||||
Views where each View is consistent with all other Views, then it is a
|
||||
Model. If there is a set of Views where each View is not consistent with
|
||||
all other Views, then it is data."* README.md's own cross-referencing
|
||||
(§8.1's kill-switch finding surfaced in §2's requirement, §3's function
|
||||
table, and §9's roadmap alike) is what keeps it a Model rather than nine
|
||||
unrelated documents.
|
||||
|
||||
## 3. The Ontology — Oikos's domain-specific language
|
||||
|
||||
Holt's Ontology has two jobs: it is the vocabulary every Viewpoint's
|
||||
content must be expressed in, and it is what makes Views from different
|
||||
parts of the Model comparable rather than coincidentally similar-looking.
|
||||
Oikos needs this at two levels, and — unusually for a Holt-style
|
||||
exercise — one of them was **already built in code**, not invented for
|
||||
this documentation pass.
|
||||
|
||||
### 3.1 Layer A — the SE meta-ontology (concepts used to talk *about* the Model)
|
||||
|
||||
This is the vocabulary this Framework document and README.md are written
|
||||
in. It is Holt's own vocabulary, restated as a concept diagram rather than
|
||||
prose, per his own example in the book (a Need Description View "visualized
|
||||
using UML Notation — specifically, a Diagram known as the class diagram,
|
||||
where each need is represented as a UML class," p. 38):
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class System
|
||||
class Model {
|
||||
+isSingleSourceOfTruth bool
|
||||
}
|
||||
class View {
|
||||
+stakeholders
|
||||
+value
|
||||
+information
|
||||
}
|
||||
class Viewpoint {
|
||||
+stakeholderQuestion
|
||||
+valueQuestion
|
||||
+informationQuestion
|
||||
}
|
||||
class Notation
|
||||
class Diagram
|
||||
class Ontology
|
||||
class Framework
|
||||
class ProcessSet
|
||||
class Stakeholder
|
||||
|
||||
Model "1" --> "1" System : abstracts
|
||||
Model "1" o-- "many" View : is made up of
|
||||
View ..|> Viewpoint : conforms to
|
||||
View "1" --> "1..many" Diagram : visualized through
|
||||
Diagram "many" --> "1" Notation : belongs to
|
||||
Viewpoint "many" --> "1" Ontology : traces terminology to
|
||||
Framework "1" o-- "1" Ontology : contains
|
||||
Framework "1" o-- "many" Viewpoint : contains
|
||||
Stakeholder "many" --> "many" Viewpoint : interested in
|
||||
```
|
||||
|
||||
### 3.2 Layer B — the Oikos domain ontology (the concepts inside the Views)
|
||||
|
||||
This is the part that already exists as running code, not something this
|
||||
documentation pass invented: `seeds/ontology.yaml` (952 lines, ingested by
|
||||
migration `001_ontology.up.sql` into the `entity_types`/`relationship_types`
|
||||
tables) *is* Holt's Ontology for this System — a machine-enforced
|
||||
domain-specific language that every Signal, Execution, Approval, and Task
|
||||
discussed anywhere in the Model traces back to. The full treatment — all 60
|
||||
entity types, the complete 47-relationship catalog (verified directly
|
||||
against the seed file; [ADR-0014](../adr/0014-entity-model.md) §1/§4
|
||||
records an earlier 2026-07-08 snapshot of 56 types and 34 relationships,
|
||||
since grown), and all six registered lifecycle state machines — is
|
||||
[ontology.md](ontology.md), Viewpoint 11 below. What follows here is the
|
||||
condensed version, sufficient only to make this section's point:
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Entity {
|
||||
+UUID id
|
||||
+string slug
|
||||
+string type
|
||||
+string state
|
||||
}
|
||||
class ComputeEntity
|
||||
class Network
|
||||
class Container
|
||||
class Service
|
||||
class Agent
|
||||
class Signal {
|
||||
+string kind
|
||||
+string severity
|
||||
+string state
|
||||
}
|
||||
class Classification {
|
||||
+string riskClass
|
||||
+string route
|
||||
}
|
||||
class Execution {
|
||||
+string status
|
||||
}
|
||||
class Approval {
|
||||
+string status
|
||||
}
|
||||
class Pattern {
|
||||
+float confidence
|
||||
+string status
|
||||
}
|
||||
class Skill
|
||||
class Task {
|
||||
+string goal
|
||||
+string status
|
||||
+string outcome
|
||||
}
|
||||
class KnowledgeEntity
|
||||
|
||||
Entity <|-- ComputeEntity
|
||||
Entity <|-- Network
|
||||
Entity <|-- Container
|
||||
Entity <|-- Service
|
||||
Entity <|-- Agent
|
||||
Entity <|-- Signal
|
||||
Entity <|-- Classification
|
||||
Entity <|-- Execution
|
||||
Entity <|-- Approval
|
||||
Entity <|-- Pattern
|
||||
Entity <|-- Skill
|
||||
Entity <|-- Task
|
||||
Entity <|-- KnowledgeEntity
|
||||
|
||||
Signal "many" --> "1" Entity : about
|
||||
Classification "1" --> "1" Signal : classifies
|
||||
Classification "1" --> "1" Execution : precedes
|
||||
Execution "many" --> "1" Entity : targets
|
||||
Execution "many" --> "1" Agent : performs
|
||||
Approval "1" --> "1" Execution : decides
|
||||
Task "1" --> "many" Execution : requests via run
|
||||
Task "many" --> "many" Entity : involves
|
||||
KnowledgeEntity "many" --> "many" Entity : about
|
||||
KnowledgeEntity "many" --> "1" Task : outcome_of
|
||||
Pattern "1" --> "many" Execution : informed_by
|
||||
```
|
||||
|
||||
**This is the elegant accident worth naming plainly:** Oikos was not built
|
||||
by someone following Holt's method, yet its own architecture independently
|
||||
arrived at "the domain concepts are an Ontology, ingested once, and
|
||||
everything else traces back to it" — `seeds/ontology.yaml` → DB tables →
|
||||
every entity, signal, execution, and relationship in the system. That is
|
||||
Holt's Ontology principle, implemented as infrastructure rather than as a
|
||||
documentation artifact. The gap is not that the Ontology is missing; it's
|
||||
that, until this document, nothing had stated the correspondence between
|
||||
"the ontology" as oikos's engineers already use the word and "the Ontology"
|
||||
as Holt's MBSE method uses it. They are the same thing, at the domain
|
||||
layer.
|
||||
|
||||
### 3.3 Where Layer A and Layer B meet
|
||||
|
||||
Layer A (SE meta-ontology) is what makes README.md's Views *disciplined* —
|
||||
each one answers Holt's three questions (§4 below). Layer B (the Oikos
|
||||
domain ontology) is what makes README.md's Views *say the same thing
|
||||
consistently* — "risk class," "entity," "signal," and "execution" mean one
|
||||
thing throughout the whole Model because they mean one thing in
|
||||
`seeds/ontology.yaml`, not because nine separately-written documents
|
||||
happened to agree.
|
||||
|
||||
## 4. The Viewpoint Catalog — the Framework's template for Views
|
||||
|
||||
Per Holt (p. 39-40), a Viewpoint stores the answers to three questions —
|
||||
*which Stakeholders, why (what value), what information* — plus a fourth,
|
||||
*what Notation* — so that every View built from it is automatically
|
||||
consistent. Below is that template applied retroactively to each of
|
||||
README.md's nine Views, which is itself a useful audit: a View that can't
|
||||
honestly answer these four questions is not a valid View by Holt's own
|
||||
test (p. 35), and is a candidate for removal.
|
||||
|
||||
| Viewpoint | Which Stakeholders (§1.2) | Why — what value | What information | Notation |
|
||||
|---|---|---|---|---|
|
||||
| **1. Mission & Context** | Operator; future engineers/agents onboarding | Establishes why design choices elsewhere aren't arbitrary; sets the system boundary so later Views don't have to re-litigate scope | Mission statement, stakeholder table, mission drivers, boundary diagram, operational concept | Prose + flowchart |
|
||||
| **2. Requirements** | Operator; anyone implementing against a requirement | Traces every "the system shall" back to a source and forward to an implementation status, so intent and reality can be compared | Requirement ID, statement, source, status, organized by OODA phase + NFRs | Structured table |
|
||||
| **3. Functional Architecture** | Engineers extending decision/execution logic | Prevents the single most expensive mistake in this codebase — extending the wrong package because it has the right name | Function decomposition, function-to-component allocation (expected vs. actual owner), the `run` gate flow, the Task lifecycle | flowchart + allocation table |
|
||||
| **4. Physical Architecture** | Operator deploying/debugging the stack; on-call | Answers "what is actually running and where" independent of what the code *could* do | Component block diagram, deployment topology, trust zones, external couplings | flowchart |
|
||||
| **5. Interfaces (ICD)** | Anyone integrating a new MCP client, or reading/writing the API | A single place to find every tool/route/event without reading source | MCP tool catalog, REST groups, SSE event types, auth model | Tables |
|
||||
| **6. Behavior** | Engineers reasoning about a specific flow (an approval, a task) end to end | State machines and sequences are where "is this actually enforced" questions get answered, not functional prose | Signal/Execution/Approval state machines, `run`-gate sequence, auto-continuation sequence, Task sequence | stateDiagram-v2 + sequenceDiagram |
|
||||
| **7. Verification & Validation** | Operator deciding whether to trust a change; anyone auditing test coverage | Distinguishes "we checked this" from "we assume this" | CI pipeline, evals, health checks as continuous verification, deploy/rollback, explicit list of what's *not* covered | Prose + flowchart |
|
||||
| **8. Risk & Safety** | Operator; anyone reasoning about blast radius of agent autonomy | The single highest-consequence question this Model answers: what actually stops a bad action | Kill-switch gap finding, defense-in-depth layers, threat model, known open gaps, what's structurally guaranteed | Prose + tables |
|
||||
| **9. Roadmap & Traceability** | Operator planning what to fix next; future documentation maintainers | The authoritative status matrix every other View's ✅/⚠/❌ marker derives from | North-star status, code-real/dead-code/schema-only matrix, doc/code divergences, suggested next steps | Tables |
|
||||
| **10. Component** *(repeating Viewpoint — one View per component)* | An engineer about to change a specific package | Prevents extending the wrong implementation of something that exists twice (§9.2's dead-code/live-code pairs), or missing a known issue local to that package | Internal structure (files, key functions, file:line), behavior specific to that component, interfaces it owns, known issues — instantiated once per component in [components.md](components.md) | flowchart + tables |
|
||||
| **11. Ontology** *(repeating Viewpoint — one View per ontology facet)* | An engineer adding/changing an entity or relationship type; anyone checking whether a term used elsewhere in the Model traces back to something real | Is the check against Holt's own biggest MBSE risk (p. 35) applied to the Ontology itself — prevents treating the domain vocabulary as informal prose when it's actually a machine-enforced schema with real transition gates | Entity type hierarchy, relationship catalog, lifecycle state machines with their `requires:` gates, concrete population — instantiated as four Views in [ontology.md](ontology.md) | graph + stateDiagram-v2 + tables |
|
||||
|
||||
**Three things this table makes visible that weren't visible before:**
|
||||
|
||||
1. Every Viewpoint's "why" is stated in terms of a **decision or mistake it
|
||||
prevents**, not merely a topic it covers — closer to Holt's requirement
|
||||
that a View "must add value" (p. 35) than a topic-based table of
|
||||
contents would be.
|
||||
2. Viewpoints 10 and 11 are structurally different from 1-9: each is a
|
||||
**repeating Viewpoint** — one template, instantiated multiple times.
|
||||
Viewpoint 10 produces eight Views, once per component
|
||||
([components.md](components.md)); Viewpoint 11 produces four, once per
|
||||
ontology facet ([ontology.md](ontology.md)). Holt's method doesn't
|
||||
forbid this; a Viewpoint is a template, and nothing says a template can
|
||||
only be used once.
|
||||
3. There is still no Viewpoint in this catalog for "document every class
|
||||
exhaustively regardless of whether anyone asked" — Viewpoints 10 and 11
|
||||
are scoped to named, narrow Stakeholder questions ("an engineer about to
|
||||
change this component," "an engineer adding a new type"), not a blanket
|
||||
documentation mandate.
|
||||
Per Holt's own worked example (the Need Description View, p. 38-39), a
|
||||
collection of information that can't name an interested Stakeholder is
|
||||
not a View; it would just be generated documentation nobody reads,
|
||||
which is the exact failure mode Holt calls out as the biggest risk in
|
||||
adopting MBSE (p. 35).
|
||||
|
||||
## 5. Compliance
|
||||
|
||||
Holt names three categories of best-practice source (p. 46) a Framework
|
||||
can be checked against. Being direct about which apply here and which
|
||||
don't, rather than implying certification that doesn't exist:
|
||||
|
||||
| Category | Holt's examples | Oikos's position |
|
||||
|---|---|---|
|
||||
| **Process-based standards** (how work is done) | ISO 15288 | Not formally adopted. This repo's own process conventions (ADRs, `plans/`, PR review) are the de facto Process Set — informally rigorous, not standards-mapped. |
|
||||
| **Framework-based standards** (what information is produced) | ISO 42010, MODAF, DoDAF, NAF, UAF, Zachman | **Informally aligned, not certified.** This Framework borrows ISO 42010's Viewpoint/View vocabulary (which Holt's own method is built on) but has not been checked against the standard's actual conformance clauses. Say this plainly rather than imply an audit that hasn't happened. |
|
||||
| **Application-based standards** (domain-specific: safety, security, usability) | — | Partially present in spirit: [Risk & Safety](README.md#8-risk--safety) documents a real threat model and known gaps, but there is no adopted external security standard (e.g., no formal threat-modeling framework like STRIDE was used — the threat model in ADR-0007 is bespoke). |
|
||||
|
||||
The honest summary: this Framework's compliance posture is **methodological
|
||||
alignment with ISO 42010's core idea (Stakeholders → concerns → Viewpoints
|
||||
→ Views), not standards certification.** Claiming more than that would
|
||||
itself violate the documentation set's own governing discipline (state
|
||||
verified findings as verified, not aspirational ones as achieved).
|
||||
|
||||
## 6. Tools — Implementation, and its honest limit
|
||||
|
||||
Holt is specific that a good MBSE tool does two things: it *implements the
|
||||
Notation* (enforces SysML's syntax/semantics the way a word processor
|
||||
enforces spelling) and it *implements the Framework* (has the Ontology and
|
||||
Viewpoints "programmed into it" as a profile, p. 44-45).
|
||||
|
||||
Neither is true here, and it matters to say so:
|
||||
|
||||
- **Notation tooling**: Markdown + Mermaid, rendered by GitHub/a Markdown
|
||||
viewer. Mermaid's flowchart/stateDiagram/sequenceDiagram/classDiagram
|
||||
grammars are enforced (a malformed diagram fails to render — as
|
||||
happened once already in this documentation effort and was fixed), but
|
||||
there is no semantic check that, say, a state machine diagram in
|
||||
[§6](README.md#6-behavior) actually matches the Go code's real
|
||||
transitions. That check was done by hand, once, for this pass — it will
|
||||
drift the moment the code changes and nobody re-verifies it.
|
||||
- **Framework tooling**: there is no tool with this Ontology or these
|
||||
Viewpoints "programmed in." Nothing prevents a future edit to README.md
|
||||
from adding a View that fails Holt's three-question test, or from
|
||||
introducing a term that doesn't trace back to `seeds/ontology.yaml`.
|
||||
The only enforcement mechanism is a human (or an agent) re-reading this
|
||||
Framework document before extending the Model — which is precisely why
|
||||
this document needed to exist as a separate, explicit artifact rather
|
||||
than staying implicit in how README.md happened to get organized.
|
||||
|
||||
## 7. Process Set — how this Framework is developed and used
|
||||
|
||||
Holt separates Framework (what) from Process Set (how) specifically so
|
||||
that different projects can share one Framework under different levels of
|
||||
rigor (p. 41-42). For this repository, the Process Set is:
|
||||
|
||||
1. **Establishing a new Viewpoint**: propose it here in §4, answering all
|
||||
four questions before writing the View it justifies. If it can't answer
|
||||
them, per Holt's own rule (p. 35), it doesn't get written.
|
||||
2. **Extending the Ontology**: changes to `seeds/ontology.yaml` are the
|
||||
authoritative act — this document's §3.2 is a description of that file,
|
||||
not an independent source, and must be re-derived from it if it drifts.
|
||||
3. **Updating a View**: per README.md's own closing section ("Keeping this
|
||||
model current"), a code change updates the View whose Viewpoint claims
|
||||
that information, and — if it resolves or introduces a finding in
|
||||
[§9 Roadmap & Traceability](README.md#9-roadmap--traceability) — that
|
||||
matrix is updated in the same pass.
|
||||
4. **Compliance review**: informal, human-in-the-loop (§5) — there is no
|
||||
scheduled re-audit; drift is caught opportunistically, the same way the
|
||||
kill-switch gap in [§8.1](README.md#81-the-kill-switch-gap-verified-most-important-finding-in-this-model)
|
||||
was caught by direct verification during a documentation pass rather
|
||||
than by a standing process designed to catch it.
|
||||
|
||||
## 8. Relationship between this Framework and the Model
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ONT["Ontology \n seeds and ADR-0014"] --> FW["Framework \n this document"]
|
||||
VP["Viewpoint catalog \n Section 4 of this document"] --> FW
|
||||
FW --> MDL["Model \n README.md, Viewpoints 1 to 9"]
|
||||
FW --> CV["Model \n components.md, Viewpoint 10\nrepeated per component"]
|
||||
FW --> OV["Model \n ontology.md, Viewpoint 11\nrepeated per ontology facet"]
|
||||
MDL --> V1["View 1..9"]
|
||||
CV --> V2["View 10a..10h"]
|
||||
OV --> V3["View 11a..11d"]
|
||||
```
|
||||
|
||||
Read [README.md](README.md) for the Model's concern-based Views,
|
||||
[components.md](components.md) for its component-based Views, and
|
||||
[ontology.md](ontology.md) for the Ontology's own full treatment (the
|
||||
sketch in §3 above is deliberately condensed). Read this document when you
|
||||
are deciding whether a new View belongs in any of the three, when a term in
|
||||
the Model feels like it's drifted from what `seeds/ontology.yaml` actually
|
||||
defines, or when onboarding someone who needs to understand not just *what
|
||||
the system is* but *why this documentation is shaped the way it is*.
|
||||
444
docs/mbse/ontology.md
Normal file
444
docs/mbse/ontology.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# Oikos — Ontology Views
|
||||
|
||||
> Companion to [the Framework](framework.md), [the Model](README.md), and
|
||||
> [the Component Views](components.md). Holt calls Ontology "arguably the
|
||||
> single most important part of MBSE, as all of the other elements that
|
||||
> make up MBSE are ultimately traceable back to [it]" (*Systems Engineering
|
||||
> Demystified*, 2nd ed., Ch. 2, p. 40). [framework.md §3](framework.md#3-the-ontology--oikoss-domain-specific-language)
|
||||
> sketched this in condensed form (13 classes) to make one point: Oikos's
|
||||
> domain ontology already exists as running code, not documentation. This
|
||||
> document is the fuller treatment that sketch promised — a repeating
|
||||
> Viewpoint (registered as Viewpoint 11 in
|
||||
> [framework.md §4](framework.md#4-the-viewpoint-catalog--the-frameworks-template-for-views)),
|
||||
> instantiated as four Views below.
|
||||
|
||||
**Every fact in this document was read directly from `seeds/ontology.yaml`
|
||||
during this pass** (not carried over from ADR-0014's summary, though it is
|
||||
cross-checked against it) — where the two disagree, that disagreement is
|
||||
itself reported as a finding, not silently reconciled.
|
||||
|
||||
**Stakeholders for all four Views below:** engineers adding a new entity or
|
||||
relationship type, anyone reasoning about whether a lifecycle transition is
|
||||
actually gated or just documented, and anyone deciding whether a term used
|
||||
elsewhere in this documentation set means what they think it means.
|
||||
**Why they earn their place:** every Viewpoint in
|
||||
[framework.md §4](framework.md#4-the-viewpoint-catalog--the-frameworks-template-for-views)
|
||||
"traces terminology to the Ontology" (§3.1 of that document) — these four
|
||||
Views are where that tracing actually terminates. **Notation:** tables
|
||||
(the source data), Mermaid `graph`/`stateDiagram-v2` (the structure).
|
||||
|
||||
## Contents
|
||||
|
||||
| View | Answers |
|
||||
|---|---|
|
||||
| [11a. Entity Type Hierarchy](#11a-entity-type-hierarchy) | What can exist, and how is it classified? |
|
||||
| [11b. Relationship Catalog](#11b-relationship-catalog) | How can two entities be connected, and with what multiplicity? |
|
||||
| [11c. Lifecycle State Machines](#11c-lifecycle-state-machines) | What states can a governed entity be in, and what gates each transition? |
|
||||
| [11d. Concrete Population](#11d-concrete-population) | What's actually instantiated, versus merely possible? |
|
||||
|
||||
---
|
||||
|
||||
## 11a. Entity Type Hierarchy
|
||||
|
||||
60 entity types, 5 abstract (cannot be instantiated directly — they exist
|
||||
only as polymorphic relationship endpoints and `is-a` parents), organized
|
||||
by `domain:` (8 values) and `layer:` (4 values: meta, infrastructure,
|
||||
governance, cognition).
|
||||
|
||||
| Layer | Domains it contains | Entity type count |
|
||||
|---|---|---|
|
||||
| `meta` | meta | 1 (`entity`, the abstract root) |
|
||||
| `infrastructure` | physical, compute, network, storage, software, external | 40 |
|
||||
| `governance` | identity | 7 |
|
||||
| `cognition` | cognition | 12 |
|
||||
|
||||
| Domain | Count | Abstract types in this domain |
|
||||
|---|---|---|
|
||||
| compute | 11 | `compute-entity`, `machine`, `container` |
|
||||
| network | 10 | `network` |
|
||||
| cognition | 12 | *(none)* |
|
||||
| identity | 7 | *(none)* |
|
||||
| software | 7 | *(none)* |
|
||||
| external | 4 | *(none)* |
|
||||
| storage | 4 | *(none)* |
|
||||
| physical | 4 | *(none)* |
|
||||
| meta | 1 | `entity` |
|
||||
|
||||
One 60-node diagram doesn't fit on a screen and, worse, tempts you to fall
|
||||
back on subgraph grouping instead of explicit edges for the flatter
|
||||
domains — which is what an earlier version of this section did: most leaf
|
||||
types were boxed together visually but had no drawn `-->` from `entity` at
|
||||
all. Split by domain instead, every type below has an explicit parent
|
||||
edge — nothing is implied by proximity alone.
|
||||
|
||||
### Layer overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
entity["entity — abstract root\nlayer: meta"] --> INFRA["infrastructure layer\n40 types — physical, compute, network,\nstorage, software, external"]
|
||||
entity --> GOV["governance layer\n7 types — identity domain"]
|
||||
entity --> COG["cognition layer\n12 types"]
|
||||
```
|
||||
|
||||
### Domain: physical (4 types, all flat)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
entity["entity"] --> site
|
||||
entity --> ups
|
||||
entity --> sensor
|
||||
entity --> peripheral
|
||||
```
|
||||
|
||||
### Domain: compute (11 types — the deepest nesting in the Ontology)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
entity["entity"] --> ce["compute-entity — abstract"]
|
||||
entity --> hypervisor
|
||||
ce --> machine["machine — abstract"]
|
||||
ce --> vm
|
||||
ce --> container["container — abstract"]
|
||||
machine --> proxmoxhost["proxmox-host"]
|
||||
machine --> standalone["standalone-server"]
|
||||
machine --> workstation
|
||||
machine --> appliance
|
||||
container --> lxc
|
||||
container --> dockercontainer["docker-container"]
|
||||
```
|
||||
|
||||
### Domain: network (10 types)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
entity["entity"] --> net["network — abstract"]
|
||||
entity --> netiface["network-interface"]
|
||||
entity --> dnszone["dns-zone"]
|
||||
entity --> dnsrecord["dns-record"]
|
||||
entity --> ingress["ingress-route"]
|
||||
entity --> certificate
|
||||
entity --> firewallrule["firewall-rule"]
|
||||
net --> lan
|
||||
net --> mesh
|
||||
net --> vlan
|
||||
```
|
||||
|
||||
### Domain: storage (4 types, all flat)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
entity["entity"] --> storagepool["storage-pool"]
|
||||
entity --> volume
|
||||
entity --> backuptarget["backup-target"]
|
||||
entity --> dataset
|
||||
```
|
||||
|
||||
`storage-pool`/`volume`/`dataset` look like they should nest (a pool
|
||||
*contains* volumes, a volume *holds* datasets) — they don't, in the type
|
||||
hierarchy. That containment is a **relationship** (`contains`,
|
||||
`holds-dataset`, [§11b](#11b-relationship-catalog)), not an `is-a` parent.
|
||||
Worth stating plainly since the two are easy to conflate: `parent:` says
|
||||
"this is a kind of that"; a relationship says "this instance is connected
|
||||
to that instance." Storage is the domain where the difference is most
|
||||
visible.
|
||||
|
||||
### Domain: software (7 types, all flat)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
entity["entity"] --> service
|
||||
entity --> application
|
||||
entity --> configrepo["config-repo"]
|
||||
entity --> deploypipeline["deploy-pipeline"]
|
||||
entity --> packageset["package-set"]
|
||||
entity --> cluster
|
||||
entity --> composestack["compose-stack"]
|
||||
```
|
||||
|
||||
### Domain: external (4 types, all flat)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
entity["entity"] --> domainreg["domain-registration"]
|
||||
entity --> cloudservice["cloud-service"]
|
||||
entity --> isplink["isp-link"]
|
||||
entity --> vendordep["vendor-dependency"]
|
||||
```
|
||||
|
||||
### Domain: identity (governance layer, 7 types, all flat)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
entity["entity"] --> person
|
||||
entity --> agent
|
||||
entity --> idp["identity-provider"]
|
||||
entity --> account
|
||||
entity --> secret
|
||||
entity --> key
|
||||
entity --> accessgrant["access-grant"]
|
||||
```
|
||||
|
||||
### Domain: cognition (12 types, all flat — the domain the agent's own logic runs on)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
entity["entity"] --> check
|
||||
entity --> signal
|
||||
entity --> classification
|
||||
entity --> execution
|
||||
entity --> feedback
|
||||
entity --> pattern
|
||||
entity --> skill
|
||||
entity --> approval
|
||||
entity --> document
|
||||
entity --> runbook
|
||||
entity --> investigation
|
||||
entity --> task["task — added after ADR-0014"]
|
||||
```
|
||||
|
||||
Every one of the 60 types above is a direct or indirect child of `entity`;
|
||||
none is disconnected. The full flat list — every type with its exact
|
||||
`parent:` — lives in `seeds/ontology.yaml` directly; reproducing all 60
|
||||
rows as a table here would duplicate this section rather than clarify it.
|
||||
|
||||
**Finding: `task` is new since ADR-0014.** ADR-0014 (2026-07-08) documents
|
||||
56 entity types under a hierarchy diagram that does not include `task` —
|
||||
four fewer than the 60 verified here, meaning more than just `task` was
|
||||
added in the interim (`task` accounts for one of the four) —
|
||||
[README.md §1.5](README.md#15-operational-concept--the-ooda-loop) and
|
||||
[framework.md §2](framework.md#2-the-goal--system-and-model-made-concrete)
|
||||
both describe the Task model as a 2026-07-11 addition
|
||||
(`plans/done/2026-07-11-goal-oriented-chat-control-panel.md`), after
|
||||
ADR-0014 was written. `task` is now entity type #60, `domain: cognition`,
|
||||
`layer: cognition`, described in the seed as *"A goal-structured unit of
|
||||
agent work — one chat/session elevated to a task with a plan, lifecycle
|
||||
status, and outcome."* This is exactly what Holt's Ontology principle
|
||||
predicts: a new concept in the Model
|
||||
([the Task lifecycle View](README.md#34-functional-flow--the-task-lifecycle-f3f4f5-packaged-for-a-human))
|
||||
required a new term in the Ontology before it could be modeled
|
||||
consistently — and the term was in fact added, not left implicit.
|
||||
|
||||
## 11b. Relationship Catalog
|
||||
|
||||
**47 relationship types**, each with a fixed `source → target` type pair
|
||||
and a cardinality. This is the complete, current catalog — not the
|
||||
5 illustrative example-graphs ADR-0014 used to gesture at a smaller set.
|
||||
|
||||
**Finding: this catalog has grown since ADR-0014.** ADR-0014 (2026-07-08)
|
||||
titles its equivalent section "The Edge Catalog (34 edges)." Verified
|
||||
directly against `seeds/ontology.yaml` during this pass: **47** relationship
|
||||
types exist today — 13 more than ADR-0014 recorded. This is expected drift
|
||||
over an 8-day span of active development (the Task model alone plausibly
|
||||
added `involves`; `part-of` supports the `compose-stack` grouping), not a
|
||||
documentation error — ADR-0014 is a point-in-time record and is not edited
|
||||
after acceptance, per this repo's own convention
|
||||
([docs/adr/README.md](../adr/README.md)). It is reported here so nobody
|
||||
treats ADR-0014's count as current.
|
||||
|
||||
| Relationship | Source → Target | Cardinality |
|
||||
|---|---|---|
|
||||
| `hosts` | machine → compute-entity | one-to-many |
|
||||
| `runs-hypervisor` | machine → hypervisor | one-to-one |
|
||||
| `member-of` | proxmox-host → cluster | many-to-one |
|
||||
| `part-of` | docker-container → compose-stack | many-to-one |
|
||||
| `provides` | compute-entity → service | one-to-many |
|
||||
| `runs` | service → application | one-to-many |
|
||||
| `configured-by` | entity → config-repo | many-to-one |
|
||||
| `deploys-to` | deploy-pipeline → entity | many-to-one |
|
||||
| `routes-to` | ingress-route → service | many-to-one |
|
||||
| `secured-by` | ingress-route → identity-provider | many-to-one |
|
||||
| `uses-certificate` | ingress-route → certificate | many-to-one |
|
||||
| `authenticates-via` | service → identity-provider | many-to-one |
|
||||
| `in-zone` | dns-record → dns-zone | many-to-one |
|
||||
| `resolves-to` | dns-record → entity | many-to-one |
|
||||
| `depends-on` | service → service | many-to-many |
|
||||
| `connects-via` | compute-entity → network | many-to-many |
|
||||
| `has-interface` | compute-entity → network-interface | one-to-many |
|
||||
| `interface-on` | network-interface → network | many-to-one |
|
||||
| `mounts` | compute-entity → volume | many-to-many |
|
||||
| `stores-on` | compute-entity → storage-pool | many-to-many |
|
||||
| `contains` | storage-pool → volume | one-to-many |
|
||||
| `holds-dataset` | volume → dataset | one-to-many |
|
||||
| `backs-up-to` | entity → backup-target | many-to-many |
|
||||
| `powered-by` | machine → ups | many-to-one |
|
||||
| `located-at` | machine → site | many-to-one |
|
||||
| `registered-with` | domain-registration → vendor-dependency | many-to-one |
|
||||
| `owns` | person → agent | one-to-many |
|
||||
| `authenticates` | identity-provider → person | one-to-many |
|
||||
| `holds-grant` | agent → access-grant | one-to-many |
|
||||
| `grants` | access-grant → secret | many-to-one |
|
||||
| `can-decrypt` | compute-entity → secret | many-to-many |
|
||||
| `checks` | check → entity | many-to-one |
|
||||
| `raises` | check → signal | one-to-many |
|
||||
| `about` | entity → entity | many-to-many |
|
||||
| `classifies` | classification → signal | many-to-one |
|
||||
| `precedes` | classification → execution | one-to-one |
|
||||
| `targets` | execution → entity | many-to-one |
|
||||
| `requires-approval` | execution → approval | one-to-one |
|
||||
| `performs` | agent → execution | one-to-many |
|
||||
| `decides` | person → approval | one-to-many |
|
||||
| `produces` | execution → feedback | one-to-one |
|
||||
| `contributes-to` | feedback → pattern | many-to-many |
|
||||
| `informs` | pattern → skill | many-to-one |
|
||||
| `guides` | skill → classification | one-to-many |
|
||||
| `documents` | document → entity | many-to-one |
|
||||
| `involves` | task → entity | many-to-many |
|
||||
| `procedure-for` | runbook → entity | many-to-many |
|
||||
|
||||
Grouped by theme, the same 47 rows read as five coherent sub-ontologies —
|
||||
this is the grouping ADR-0014 used, now complete rather than illustrative:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Cognition["Cognition — the OODA edges"]
|
||||
CK["check"] -->|raises| SG["signal"]
|
||||
CK -->|checks| EN["entity"]
|
||||
CL["classification"] -->|classifies| SG
|
||||
CL -->|precedes| EX["execution"]
|
||||
EX -->|targets| EN
|
||||
EX -->|requires-approval| AP["approval"]
|
||||
AG["agent"] -->|performs| EX
|
||||
PR["person"] -->|decides| AP
|
||||
EX -->|produces| FB["feedback"]
|
||||
FB -->|contributes-to| PT["pattern"]
|
||||
PT -->|informs| SK["skill"]
|
||||
SK -->|guides| CL
|
||||
TK["task"] -->|involves| EN
|
||||
DC["document"] -->|documents| EN
|
||||
RB["runbook"] -->|procedure-for| EN
|
||||
end
|
||||
```
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Governance["Governance — identity and access"]
|
||||
P["person"] -->|owns| A["agent"]
|
||||
IDP["identity-provider"] -->|authenticates| P
|
||||
A -->|holds-grant| AG["access-grant"]
|
||||
AG -->|grants| S["secret"]
|
||||
CE["compute-entity"] -->|can-decrypt| S
|
||||
end
|
||||
```
|
||||
|
||||
The remaining three groups (Infrastructure Topology, Network, Service
|
||||
Dependencies) are unchanged in shape from
|
||||
[ADR-0014 §4](../adr/0014-entity-model.md) — that ADR's diagrams for those
|
||||
three are still an accurate illustrative subset of the table above; only
|
||||
the Cognition and Governance groups gained new edges (`involves`,
|
||||
`part-of`) worth re-drawing.
|
||||
|
||||
## 11c. Lifecycle State Machines
|
||||
|
||||
Six lifecycles are formally registered in `seeds/ontology.yaml`'s
|
||||
`lifecycles:` block, each a named state machine with `states`,
|
||||
`default_state`, `terminal_states`, and per-transition `requires:` — named
|
||||
checks that [internal/ontology](../../internal/ontology) implements in Go.
|
||||
This is the mechanism, not just the diagram: a transition without a
|
||||
satisfied `requires:` check is refused at the code level, for these six
|
||||
types.
|
||||
|
||||
**Refinement to README.md's Behavior view.** The `execution` lifecycle as
|
||||
registered here has **13 states**, including a `verifying` state distinct
|
||||
from `executing`, and a recovery transition `timed_out → verifying`
|
||||
("check if the command completed anyway" — the seed's own comment).
|
||||
[README.md §6.2](README.md#62-execution-state-machine--schema-defined-convention-enforced)'s
|
||||
Execution state diagram previously omitted `verifying` as a separate
|
||||
state and has been corrected there to match; the diagram below is the
|
||||
ontology-accurate version and the two now agree.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> proposed
|
||||
proposed --> approved: operator-approval
|
||||
proposed --> auto_approved: autonomy-allows
|
||||
proposed --> denied
|
||||
approved --> executing: approval-token-valid
|
||||
approved --> expired: approval-ttl-elapsed
|
||||
auto_approved --> executing
|
||||
executing --> verified: verification-passed
|
||||
executing --> failed
|
||||
executing --> timed_out
|
||||
executing --> cancelled: operator-abort
|
||||
timed_out --> verifying: check if it finished anyway
|
||||
verifying --> verified: verification-passed
|
||||
verifying --> failed
|
||||
failed --> rolled_back: rollback-procedure-exists
|
||||
failed --> rollback_failed
|
||||
verified --> [*]
|
||||
denied --> [*]
|
||||
expired --> [*]
|
||||
cancelled --> [*]
|
||||
rolled_back --> [*]
|
||||
rollback_failed --> [*]
|
||||
```
|
||||
|
||||
The other five, with their `requires:` gates named explicitly (abbreviated
|
||||
where a transition has no requirement):
|
||||
|
||||
| Lifecycle | States | Terminal | Notable gated transition |
|
||||
|---|---|---|---|
|
||||
| `infrastructure` | planned, provisioning, active, migrating, failed, deprecated, destroyed | destroyed | `deprecated → destroyed` requires **five** checks at once: `backups-verified`, `secrets-revoked-and-rekeyed`, `ingress-and-dns-removed`, `no-inbound-edges`, `archaeology-entry` — the strictest single transition in the entire Ontology |
|
||||
| `signal` | raised, acknowledged, acting, muted, resolved, failed | resolved | `acknowledged → acting` requires `classification-exists` — the formal link between Orient and Decide, real in the Ontology even though [Roadmap §9.2](README.md#92-code-real--dead-code--schema-only-matrix) finds the classifier that would create that classification is dead code |
|
||||
| `approval` | pending, approved, denied, expired, revoked | denied, expired, revoked | `pending → approved` requires `token-verified`; `approved → revoked` requires `not-yet-executing` — you cannot revoke an approval whose action has already started |
|
||||
| `pattern` | hypothesized, validated, active, deprecated, invalidated | deprecated, invalidated | `hypothesized → validated` requires `evidence-count-5plus` **and** `confidence-0.7plus` jointly — matches [ADR-0006](../adr/0006-learning-proposal-only.md)'s Wilson-bound description exactly; `validated → active` requires `operator-approval`, annotated in the seed itself as *"S4: never automatic"* |
|
||||
| `skill` | drafted, tested, active, refined, failed, deprecated | deprecated | `tested → active` and `refined → active` both require `operator-approval` — a skill can be authored and tested autonomously but never self-promotes to active |
|
||||
|
||||
**Finding: `task` has no registered lifecycle.** The `task` entity type
|
||||
(§11a) has a real, documented behavior —
|
||||
[README.md §3.4](README.md#34-functional-flow--the-task-lifecycle-f3f4f5-packaged-for-a-human)
|
||||
shows `planning → awaiting_approval → executing ⇄ awaiting_input → done`/`failed`
|
||||
as a state diagram, and it is enforced in application code (the
|
||||
`agent_sessions.status` column, checked in `cmd/nomos`). But
|
||||
`seeds/ontology.yaml`'s `lifecycles:` block registers only the six
|
||||
machines above — there is no `task:` entry alongside `infrastructure`,
|
||||
`signal`, `execution`, `approval`, `pattern`, `skill`. Practically: the
|
||||
five other governed types get their transition-gating for free from the
|
||||
shared `internal/ontology` machinery (per named `requires:` checks); the
|
||||
Task lifecycle is instead hand-coded in `cmd/nomos`'s Go logic, a
|
||||
structurally different (and unaudited-by-the-shared-mechanism) enforcement
|
||||
path for what is, in every other respect, a first-class Ontology citizen.
|
||||
This is a gap worth a deliberate decision — register `task` formally, or
|
||||
document explicitly that Task's lifecycle is intentionally
|
||||
application-layer rather than Ontology-layer — not an oversight this
|
||||
document is fixing by writing it down.
|
||||
|
||||
## 11d. Concrete Population
|
||||
|
||||
What's actually instantiated versus merely possible in the type system —
|
||||
per [ADR-0014](../adr/0014-entity-model.md) §1, **not independently
|
||||
re-counted against the live database during this pass** (that would
|
||||
require DB access this documentation effort didn't use; the figures below
|
||||
are ADR-0014's, dated 2026-07-08, and should be treated as illustrative of
|
||||
shape rather than a current census):
|
||||
|
||||
| Type | Count (as of ADR-0014) | Examples |
|
||||
|---|---|---|
|
||||
| `lxc` | 19 | jellyfin, caddy, dns, gitea, nextcloud, matrix |
|
||||
| `service` | 25 | caddy, authentik, dns, jellyfin, paperless, matrix |
|
||||
| `ingress-route` | 21 | `*.hubris.network` |
|
||||
| `config-repo` | 6 | caddy-conf, gitea-customizations, mule-image |
|
||||
| `proxmox-host` | 2 | hubris, strong |
|
||||
| `workstation` | 2 | mac-mini, republic-laptop |
|
||||
| `standalone-server` | 1 | netbird-vps |
|
||||
| `vm` | 2 | zimaos, haos |
|
||||
| `storage-pool` | 3 | local-lvm-hubris, library-hubris, ludo-lvm |
|
||||
| `volume` | 2 | library, media-local |
|
||||
|
||||
**Why this View matters despite being the least current one here:** it is
|
||||
the check against over-abstraction Holt warns about (p. 35) — an Ontology
|
||||
with 60 types and 47 relationships is only worth having if real entities
|
||||
actually populate a meaningful fraction of it. 88 active entities across
|
||||
roughly a dozen concrete types (out of 55 non-abstract types) is a
|
||||
reasonable population for a homelab of this size; a future re-audit of this
|
||||
specific View is a cheap, well-scoped follow-up (query `entities GROUP BY
|
||||
type`) that this pass explicitly did not do, rather than silently assuming
|
||||
ADR-0014's numbers still hold.
|
||||
|
||||
## Keeping this document current
|
||||
|
||||
Re-derive §11a-11c directly from `seeds/ontology.yaml` whenever it changes
|
||||
— these three Views are transcriptions of that file's structure, not
|
||||
independent judgment, so they go stale the moment the file changes and
|
||||
nobody re-runs the extraction. §11d is the one View here that was already
|
||||
known to be a point-in-time snapshot when written; re-verify it against
|
||||
live DB state before relying on it for a capacity or audit decision.
|
||||
55
evals/golden.yaml
Normal file
55
evals/golden.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Golden conversation evals for the nomos agent.
|
||||
# Run: go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest evals/*.yaml
|
||||
#
|
||||
# Each conversation costs real OpenRouter credits (~$0.01–0.05). The runner
|
||||
# sends the prompt, waits for the turn to finish, optionally sends a followup,
|
||||
# and scores assertions against the final persisted transcript.
|
||||
#
|
||||
# These are STRUCTURAL assertions only — tool-call sequences, plan steps,
|
||||
# writeback, completion. Text quality is model-dependent and not scored.
|
||||
|
||||
# --- eval 1: trivial read-only task (degenerate case) ---
|
||||
- name: trivial_readonly
|
||||
prompt: "What is the state of lxc:dns? One line, no plan needed."
|
||||
assertions:
|
||||
- kind: completes
|
||||
- kind: no_propose_plan # trivial — no ceremony
|
||||
- kind: max_tool_calls
|
||||
value: 5 # get_entity + complete_task + maybe one more
|
||||
- kind: no_run # read-only, no `run` needed
|
||||
|
||||
# --- eval 2: the operator's original bug — plan + proceed ---
|
||||
- name: plan_advances_on_proceed
|
||||
prompt: "Check the uptime of lxc:gitea. Plan it out, propose the plan, then wait for my approval before running anything."
|
||||
followup: "proceed with the rest"
|
||||
assertions:
|
||||
- kind: completes
|
||||
- kind: proposes_plan_once # propose_plan called exactly once
|
||||
- kind: no_duplicate_proposal # the original bug: re-propose on "proceed"
|
||||
- kind: writes_back # ran `run` → must update_entity_attributes (D.1)
|
||||
- kind: no_duplicate_complete # C.1 — complete_task called at most once
|
||||
|
||||
# --- eval 3: UI complaint should not re-execute (C.2) ---
|
||||
- name: ui_complaint_no_rerun
|
||||
prompt: "Check the uptime of lxc:dns. Plan it out and wait for my approval."
|
||||
followup: "go ahead"
|
||||
assertions:
|
||||
- kind: completes
|
||||
- kind: proposes_plan_once
|
||||
- kind: writes_back
|
||||
# (The followup "go ahead" is approval, not a UI complaint — we'd test the
|
||||
# complaint path separately with a second followup, but that needs the
|
||||
# session to stay open after completion, which the runner doesn't support yet.
|
||||
# For now this validates the approval-vocabulary path.)
|
||||
|
||||
# --- eval 4: knowledge preferred over fleet re-execution (E.1) ---
|
||||
# A same-day fleet audit knowledge entry exists in the DB. The agent should
|
||||
# search_knowledge first and NOT run `run` against 20 LXCs.
|
||||
- name: knowledge_preferred_over_rerun
|
||||
prompt: "Give me an overview of what needs updating across the homelab, categorize by criticality. There may be a recent audit already."
|
||||
assertions:
|
||||
- kind: completes
|
||||
- kind: calls_tool
|
||||
value: search_knowledge # E.1 — must check the knowledge base first
|
||||
- kind: max_run_calls
|
||||
value: 4 # NOT 20+ — a targeted refresh only
|
||||
18
evals/iteration-followup.yaml
Normal file
18
evals/iteration-followup.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# P5 eval: iteration. A read-only task completes; the follow-up asks the
|
||||
# agent to act on what it found (a config_mutation). The second followup
|
||||
# is the approval ("go ahead") so the plan can execute. Asserts the session
|
||||
# reopens, a second plan generation is created, and the agent completes
|
||||
# both sub-tasks.
|
||||
- name: iteration-followup
|
||||
prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled? Inspect the actual service state, don't just read the DB."
|
||||
followups:
|
||||
- "The repos folder failed last time. Reset the failed service and re-run the backup."
|
||||
- "go ahead"
|
||||
assertions:
|
||||
- kind: completes
|
||||
- kind: plan_generations
|
||||
value: 2
|
||||
- kind: proposes_plan
|
||||
- kind: writes_back
|
||||
- kind: max_run_calls
|
||||
value: 40
|
||||
17
evals/iteration-readonly.yaml
Normal file
17
evals/iteration-readonly.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# P5 eval: two read-only sub-tasks back-to-back. The follow-up is also
|
||||
# read-only (no approval needed) so the agent can execute immediately after
|
||||
# proposing the second plan. Asserts the session reopens and a second plan
|
||||
# generation is created.
|
||||
- name: iteration-readonly
|
||||
prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled?"
|
||||
followups:
|
||||
- "Now check the uptime of lxc:dns."
|
||||
assertions:
|
||||
- kind: completes
|
||||
- kind: plan_generations
|
||||
value: 2
|
||||
- kind: proposes_plan
|
||||
- kind: calls_tool
|
||||
value: run
|
||||
- kind: max_run_calls
|
||||
value: 6
|
||||
10
evals/no-plan-no-run.yaml
Normal file
10
evals/no-plan-no-run.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
# P5 eval: a pure-DB Q&A that calls NO run. This is the ONLY remaining
|
||||
# carve-out from plan-first: a task that never touches a live target via
|
||||
# `run` doesn't need propose_plan (the gate only fires on run). Asserts
|
||||
# the agent answers directly and completes without ceremony.
|
||||
- name: no-plan-no-run
|
||||
prompt: "List all LXC containers and their current health."
|
||||
assertions:
|
||||
- kind: completes
|
||||
- kind: no_run
|
||||
- kind: no_propose_plan
|
||||
14
evals/plan-always-readonly.yaml
Normal file
14
evals/plan-always-readonly.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
# P5 eval: a read-only question that requires live inspection (not just DB
|
||||
# lookup). Asserts the plan-first gate works: the agent must propose_plan
|
||||
# before run, even for a trivial read-only task.
|
||||
- name: plan-always-readonly
|
||||
prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled? Inspect the actual service state, don't just read the DB."
|
||||
assertions:
|
||||
- kind: completes
|
||||
- kind: proposes_plan
|
||||
- kind: plan_before_run
|
||||
- kind: calls_tool
|
||||
value: run
|
||||
- kind: writes_back
|
||||
- kind: max_run_calls
|
||||
value: 6
|
||||
@@ -304,10 +304,24 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
ORDER BY e.slug`, rootID, depth, req.Params.RelType)
|
||||
} else {
|
||||
// Whole-graph view: pick the most-connected entities first so the
|
||||
// graph shows actual topology, not just whatever sorts first
|
||||
// alphabetically. Without this the cap fills with exec:* rows and
|
||||
// drops every host/lxc/service/vm — and every edge those entities
|
||||
// connect — because edges require both endpoints in the node set.
|
||||
nodes, err = s.queryEntities(ctx, `
|
||||
SELECT `+entityCols+` FROM entities e
|
||||
SELECT `+entityCols+`
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
ORDER BY e.slug LIMIT $1`,
|
||||
WHERE e.id IN (
|
||||
SELECT e2.id FROM entities e2
|
||||
LEFT JOIN relationships r ON r.valid_to IS NULL
|
||||
AND (r.source_id = e2.id OR r.target_id = e2.id)
|
||||
GROUP BY e2.id
|
||||
ORDER BY count(r.type) DESC, e2.slug
|
||||
LIMIT $1
|
||||
)
|
||||
ORDER BY e.slug`,
|
||||
graphNodeCap+1)
|
||||
if err == nil && len(nodes) > graphNodeCap {
|
||||
nodes = nodes[:graphNodeCap]
|
||||
|
||||
@@ -138,6 +138,13 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
s.serveOIDCToken(w, req, cfg)
|
||||
})
|
||||
|
||||
// Desktop OIDC callback — standalone HTML page that exchanges the
|
||||
// authorization code for tokens and displays the access token to copy
|
||||
// into the desktop app's Config screen.
|
||||
r.Get("/oidc-callback", func(w http.ResponseWriter, req *http.Request) {
|
||||
s.serveOIDCCallback(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())
|
||||
@@ -657,6 +664,148 @@ func (s *Server) serveOIDCToken(w http.ResponseWriter, req *http.Request, cfg co
|
||||
w.Write(respBody)
|
||||
}
|
||||
|
||||
// serveOIDCCallback serves a standalone HTML page that completes the
|
||||
// desktop OIDC login flow. Authentik redirects here with ?code=...&state=...
|
||||
// after the user authorizes. The state carries the PKCE verifier
|
||||
// (base64url-encoded, joined with "."). The page exchanges the code for
|
||||
// tokens via the token proxy, then displays the access token for the user
|
||||
// to copy into the desktop app.
|
||||
func (s *Server) serveOIDCCallback(w http.ResponseWriter, req *http.Request, cfg config.Config) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Oikos — Connect Desktop App</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a; color: #e0e0e0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; padding: 24px;
|
||||
}
|
||||
.card {
|
||||
background: #1a1a1a; border: 1px solid #2a2a2a;
|
||||
border-radius: 12px; padding: 32px; max-width: 480px; width: 100%;
|
||||
}
|
||||
h1 { font-size: 20px; margin-bottom: 8px; }
|
||||
p { font-size: 14px; color: #888; margin-bottom: 20px; }
|
||||
.spinner { margin: 24px auto; width: 32px; height: 32px; border: 3px solid #2a2a2a; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.token-box {
|
||||
background: #111; border: 1px solid #2a2a2a; border-radius: 8px;
|
||||
padding: 16px; font-family: monospace; font-size: 13px;
|
||||
word-break: break-all; margin-bottom: 16px; position: relative;
|
||||
max-height: 160px; overflow-y: auto;
|
||||
}
|
||||
.btn {
|
||||
display: block; width: 100%; padding: 12px; border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; text-align: center;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: #fff; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-secondary { background: #1a1a1a; color: #e0e0e0; border: 1px solid #2a2a2a; margin-top: 8px; }
|
||||
.btn-secondary:hover { background: #222; }
|
||||
.success { color: #22c55e; margin-bottom: 8px; font-weight: 600; }
|
||||
.error { color: #ef4444; margin-bottom: 12px; }
|
||||
.copied { color: #22c55e; font-size: 13px; text-align: center; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Connect Desktop App</h1>
|
||||
<div id="loading">
|
||||
<p>Exchanging authorization code...</p>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<div id="result" style="display:none"></div>
|
||||
</div>
|
||||
<script>
|
||||
async function main() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
|
||||
if (!code || !state) {
|
||||
showError('Missing code or state parameter from Authentik redirect.');
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = state.split('.');
|
||||
if (parts.length !== 2) {
|
||||
showError('Invalid state format.');
|
||||
return;
|
||||
}
|
||||
const [csrf, verifier] = parts;
|
||||
|
||||
const redirectURI = location.origin + '/oidc-callback';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/auth/oidc-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectURI
|
||||
})
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showError(err.error || err.message || 'Token exchange failed (' + resp.status + ')');
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = await resp.json();
|
||||
if (!tokens.access_token) {
|
||||
showError('No access token in response.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
const result = document.getElementById('result');
|
||||
result.style.display = 'block';
|
||||
result.innerHTML = '<div class="success">Authentication successful</div>' +
|
||||
'<p style="margin-bottom:8px">Copy this token into the Oikos desktop app Token tab:</p>' +
|
||||
'<div class="token-box" id="token">' + escapeHtml(tokens.access_token) + '</div>' +
|
||||
'<button class="btn btn-primary" id="copyBtn">Copy Token</button>' +
|
||||
'<button class="btn btn-secondary" onclick="location.reload()">Try Again</button>' +
|
||||
'<div class="copied" id="copied" style="display:none">Copied!</div>';
|
||||
|
||||
document.getElementById('copyBtn').addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(tokens.access_token).then(() => {
|
||||
const el = document.getElementById('copied');
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => el.style.display = 'none', 2000);
|
||||
});
|
||||
});
|
||||
} catch(e) {
|
||||
showError('Network error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
const result = document.getElementById('result');
|
||||
result.style.display = 'block';
|
||||
result.innerHTML = '<div class="error">' + escapeHtml(msg) + '</div>' +
|
||||
'<button class="btn btn-secondary" onclick="location.reload()">Try Again</button>';
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
main();
|
||||
</script>
|
||||
</body>
|
||||
</html>`)
|
||||
}
|
||||
|
||||
// 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) error {
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/dtoro/oikos/internal/policy"
|
||||
"github.com/dtoro/oikos/internal/safego"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
@@ -212,7 +211,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
InputSchema: objSchema(
|
||||
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
|
||||
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
|
||||
prop{"about", "string", "Optional entity slug this knowledge concerns (e.g. lxc:typetype, host:strong) — links the note to that entity so get_entity_knowledge surfaces it."},
|
||||
prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."},
|
||||
prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."},
|
||||
prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."},
|
||||
),
|
||||
@@ -358,186 +357,11 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
nStr(args["status"])), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (agent-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade, pct_create. pct_create provisions a NEW LXC and installs its service in one approved step — you do NOT need follow-up pct_exec calls for package installs.",
|
||||
InputSchema: objSchema(
|
||||
prop{"target", "string", "Target entity slug. For pct_create this MUST be the Proxmox HOST that will run the container (e.g. host:strong) — NOT the new LXC's name. For restart/systemctl/apt_upgrade/pct_exec use the target service/LXC slug (e.g. lxc:caddy)."},
|
||||
prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade, pct_create"},
|
||||
prop{"params", "string", "For systemctl: 'enable|disable|reload'. For pct_exec: the shell command. For apt_upgrade: 'audit|upgrade'. For pct_create: a JSON object string with keys: vmid (int, required, unused id), hostname (string, required), cores (int), memory (MB int), disk_gb (int), ip (CIDR e.g. 192.168.8.50/24, or omit/\"dhcp\" — DHCP is the safe default, see below), gw (gateway ip, static only), bridge (e.g. vmbr0/vmbr1 — WHICH BRIDGE REACHES WHICH SUBNET IS DIFFERENT PER HOST, never assume vmbr0; see below), storage (default local-lvm), template (optional — omit to auto-pick newest debian on the host), privileged (bool), nesting (bool), mounts ([]string of 'src,mp=/dst'). Example: {\"vmid\":150,\"hostname\":\"typetype\",\"cores\":2,\"memory\":2048,\"disk_gb\":16,\"ip\":\"192.168.8.50/24\",\"gw\":\"192.168.8.2\",\"bridge\":\"vmbr1\",\"nesting\":true}. pct_create is ATOMIC — it ONLY creates and starts the container (no services/post_install params anymore). Once it completes you will be automatically re-invoked with the result; install packages and run setup by issuing your OWN `run` calls against the new lxc:<hostname> target, one step at a time — you'll see each step's real output and can fix exactly the one that fails, instead of one opaque multi-minute install that either fully works or fully doesn't. STATIC IP RULE: before setting ip/gw/bridge to anything other than DHCP, use list_entities/get_entity_knowledge to find an EXISTING lxc on the SAME host whose IP is in the same /28 block, and copy its exact gw+bridge — do not invent a gateway. If no such neighbor exists, prefer ip:\"dhcp\" (proven to work, gets a real routable address) over guessing; a wrong bridge/gateway pair fails a fast pre-flight ping check now (seconds, not minutes) but is still a wasted turn — better to not guess at all."},
|
||||
),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
targetSlug, _ := args["target"].(string)
|
||||
action, _ := args["action"].(string)
|
||||
params, _ := args["params"].(string)
|
||||
sessionID, _ := args["_session_id"].(string)
|
||||
if targetSlug == "" || action == "" {
|
||||
return textResult("error: target and action required"), nil
|
||||
}
|
||||
|
||||
var targetID uuid.UUID
|
||||
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
|
||||
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
||||
}
|
||||
|
||||
// restart, pct_exec, and systemctl (outside enable/disable) route
|
||||
// through the same classify→gate path as `run` instead of executing
|
||||
// immediately over SSH with a hardcoded risk_class='reversible_low'
|
||||
// that was never actually checked against anything. Found live
|
||||
// 2026-07-10: a chat request to "restart caddy" — the fleet's
|
||||
// reverse proxy — executed instantly with zero approval, because
|
||||
// this action bypassed the classifier entirely. classifyAndGate
|
||||
// applies the same read-only/config-mutation/destructive
|
||||
// classification and approval flow the `run` tool already uses.
|
||||
if action == "restart" || action == "pct_exec" || (action == "systemctl" && params != "enable" && params != "disable") {
|
||||
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
||||
var cmd, purpose string
|
||||
switch action {
|
||||
case "restart":
|
||||
cmd = fmt.Sprintf("systemctl restart %s; sleep 1; systemctl is-active %s", svc, svc)
|
||||
purpose = "restart " + svc
|
||||
case "pct_exec":
|
||||
cmd = params
|
||||
purpose = "pct_exec (legacy) on " + targetSlug
|
||||
case "systemctl":
|
||||
cmd = fmt.Sprintf("systemctl %s %s; sleep 1; systemctl is-active %s", params, svc, svc)
|
||||
purpose = "systemctl " + params + " " + svc
|
||||
}
|
||||
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, cmd, purpose, "", sessionID), nil
|
||||
}
|
||||
|
||||
// Deduplicate: if a pending execution already exists for the same
|
||||
// target+action, return the existing one instead of creating a
|
||||
// duplicate. Prevents the LLM from re-requesting the same gated
|
||||
// action in a tool-calling loop. Only blocks when a pending
|
||||
// execution exists; completed/failed ones don't block.
|
||||
if action == "systemctl" || action == "apt_upgrade" || action == "pct_create" {
|
||||
execNamePrefix := action + " on " + targetSlug
|
||||
var existingID string
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT e.id::text FROM entities e
|
||||
JOIN executions ex ON ex.entity_id = e.id
|
||||
WHERE e.type = 'execution' AND e.name LIKE $1 AND ex.status = 'pending_approval'
|
||||
ORDER BY e.created_at DESC LIMIT 1`, execNamePrefix+"%").Scan(&existingID)
|
||||
if err == nil && existingID != "" {
|
||||
return textResult(fmt.Sprintf("%s on %s is already queued for approval — execution %s. Wait for operator approval. Do not re-request.",
|
||||
action, targetSlug, existingID)), nil
|
||||
}
|
||||
}
|
||||
|
||||
id, _ := uuid.NewV7()
|
||||
correlationID := uuid.New().String()
|
||||
|
||||
// Full UUID, not a truncated prefix: UUIDv7's leading bytes encode a
|
||||
// millisecond timestamp, so an 8-char prefix collides for real under
|
||||
// back-to-back requests (observed live: two `run` calls seconds
|
||||
// apart hit entities_slug_key). The full string is guaranteed unique.
|
||||
execName := action + " on " + targetSlug + " (" + id.String() + ")"
|
||||
execSlug := "exec:" + targetSlug + ":" + id.String()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`,
|
||||
id, execSlug, execName)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: failed to create execution: %v", err)), nil
|
||||
}
|
||||
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
|
||||
// classifyAndGate above, before this dedup+insert block.
|
||||
switch action {
|
||||
case "systemctl":
|
||||
// Only enable/disable reach this case now.
|
||||
svc := strings.TrimPrefix(targetSlug, "lxc:")
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||
createApproval(ctx, pool, id, targetID, "systemctl", svc+":"+params, "config_mutation")
|
||||
return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil
|
||||
|
||||
case "apt_upgrade":
|
||||
if params == "audit" {
|
||||
host, user, err := resolveHost(ctx, pool, targetSlug)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("resolve: %v", err)), nil
|
||||
}
|
||||
out, err := sshExec(ctx, host, user, "apt update -qq 2>&1 >/dev/null; apt list --upgradable 2>/dev/null | tail -n +2 | wc -l; apt list --upgradable 2>/dev/null | tail -n +2 | head -20")
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("apt audit error: %v", err)), nil
|
||||
}
|
||||
return textResult("apt audit:\n" + out), nil
|
||||
}
|
||||
// During an active assent window, auto-approve.
|
||||
if assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||
createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation")
|
||||
// Do NOT pre-flip approvals/executions status here (that was
|
||||
// the previous, broken "autoApprove" helper). DecideApproval
|
||||
// (invoked below) is the ONE place that transitions
|
||||
// pending_approval -> approved and dispatches the real SSH
|
||||
// work — it specifically looks for status='pending_approval'
|
||||
// to find what to run. Pre-flipping the status past that
|
||||
// state meant DecideApproval's own lookup found nothing,
|
||||
// silently no-opped, and the execution sat at 'approved'
|
||||
// forever with nothing actually running. Found live: every
|
||||
// assent-window auto-approved pct_create/apt_upgrade has
|
||||
// never actually executed, via this exact bug. Calling
|
||||
// executeApprovedViaAPI directly against the untouched
|
||||
// pending_approval row makes this identical to the manual
|
||||
// Approve-button path, just without a human click.
|
||||
//
|
||||
// context.Background(), NOT ctx: ctx is scoped to this MCP
|
||||
// tool call, cancelled the instant the chat turn's HTTP
|
||||
// response completes (every normal turn) — a goroutine
|
||||
// meant to outlive the request must not inherit its context.
|
||||
safego.Go("mcp:executeApprovedViaAPI:apt_upgrade", func() {
|
||||
executeApprovedViaAPI(context.Background(), id, targetSlug, "apt_upgrade:"+params)
|
||||
})
|
||||
slog.Info("mcp: apt_upgrade auto-approved via assent window", "execution_id", id)
|
||||
return textResult(fmt.Sprintf("apt_upgrade on %s auto-approved via assent window — execution %s running.", targetSlug, id)), nil
|
||||
}
|
||||
// upgrade requires approval — queue
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||
createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation")
|
||||
return textResult(fmt.Sprintf("apt_upgrade on %s requires approval — execution %s queued", targetSlug, id)), nil
|
||||
|
||||
case "pct_create":
|
||||
// During an active assent window, auto-approve and execute
|
||||
// instead of queuing — the operator already approved the plan.
|
||||
if assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||
createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation")
|
||||
// See the apt_upgrade case above for why there's no
|
||||
// pre-flip-status "autoApprove" step here anymore, and why
|
||||
// this uses context.Background().
|
||||
safego.Go("mcp:executeApprovedViaAPI:pct_create", func() {
|
||||
executeApprovedViaAPI(context.Background(), id, targetSlug, "pct_create:"+params)
|
||||
})
|
||||
slog.Info("mcp: pct_create auto-approved via assent window", "execution_id", id)
|
||||
return textResult(fmt.Sprintf("pct_create on %s auto-approved via assent window — execution %s running. The LXC is being provisioned now.", targetSlug, id)), nil
|
||||
}
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||
createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation")
|
||||
return textResult(fmt.Sprintf("pct_create on %s requires approval — execution %s queued. The LXC will be provisioned once approved.", targetSlug, id)), nil
|
||||
|
||||
default:
|
||||
return textResult(fmt.Sprintf("unknown action: %s. Supported: restart, systemctl, pct_exec, apt_upgrade, pct_create", action)), nil
|
||||
}
|
||||
})
|
||||
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
|
||||
// All mutations now route through `run`. The handler functions
|
||||
// (runRexecRestart, runRexecSystemctl, etc.) are kept as reference
|
||||
// for future runbook extraction — especially pct_create DNS/VMID logic.
|
||||
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
|
||||
|
||||
register(&mcp.Tool{Name: "run", Description: "Run ANY shell command against any host or LXC. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
|
||||
InputSchema: objSchema(
|
||||
@@ -661,17 +485,38 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
|
||||
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
||||
|
||||
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state",
|
||||
InputSchema: objSchema(),
|
||||
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.",
|
||||
InputSchema: objSchema(
|
||||
prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"},
|
||||
),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
state, _ := argsMap(req)["state"].(string)
|
||||
var statePtr *string
|
||||
if state != "" {
|
||||
statePtr = &state
|
||||
}
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
|
||||
e.attributes->>'lan_ip' AS lan_ip,
|
||||
st.health, st.last_check_at
|
||||
e.state,
|
||||
st.health, st.last_check_at,
|
||||
(SELECT MAX(k.created_at)
|
||||
FROM relationships r
|
||||
JOIN knowledge_entities k ON k.entity_id = r.source_id
|
||||
WHERE r.target_id = e.id
|
||||
AND r.type = 'about'
|
||||
AND r.valid_to IS NULL
|
||||
AND (k.tags @> ARRAY['audit']::text[]
|
||||
OR k.tags @> ARRAY['update']::text[]
|
||||
OR k.title ILIKE '%audit%'
|
||||
OR k.title ILIKE '%update%')
|
||||
) AS last_audited_at
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.type = 'lxc'
|
||||
ORDER BY (e.attributes->>'pve_id')::int`), "lxc_list"), nil
|
||||
AND ($1::text IS NULL OR e.state = $1)
|
||||
ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END,
|
||||
(e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
|
||||
@@ -1421,6 +1266,20 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
|
||||
actionCol := "run:" + string(runParams)
|
||||
|
||||
// P1 plan-first gate: every task must propose a plan before any `run`,
|
||||
// read-only or not. The only carve-out is a pure-DB Q&A that calls no
|
||||
// `run` at all (those never reach this code path). Without this gate the
|
||||
// SOUL.md "MANDATORY TASK FLOW" is unenforceable prose — weaker models
|
||||
// skip propose_plan and go straight to run, leaving the operator with
|
||||
// 23 individual approvals and no plan to approve (the original
|
||||
// anti-pattern the flow exists to prevent). Mirrors D.1's structural
|
||||
// refusal pattern in complete_task. sessionID == "" means a direct MCP
|
||||
// call with no nomos session (e.g. an external script) — gate is a
|
||||
// no-op there, since there's no session to hold a plan.
|
||||
if sessionID != "" && !sessionHasPlan(ctx, pool, sessionID) {
|
||||
return textResult("No plan for this session. Call set_goal then propose_plan before run — even read-only tasks require a one-step plan. A one-step plan (\"Inspect X, report, write back\") is fine for trivial questions; the gate is about ordering, not approval. Read-only commands still auto-execute once a plan exists.")
|
||||
}
|
||||
|
||||
// Dedup: an identical pending command (same target, command, and
|
||||
// purpose) blocks a re-request — stops a tool-calling loop from queuing
|
||||
// the same approval repeatedly.
|
||||
@@ -1436,6 +1295,26 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
return textResult(fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", targetSlug, existingID))
|
||||
}
|
||||
|
||||
// P5: if this is a config_mutation command, no assent window is active,
|
||||
// and there's already a pending_approval for this session, refuse —
|
||||
// don't queue a second approval. The operator should see ONE approval
|
||||
// (the plan), approve it (which opens the assent window), and then all
|
||||
// subsequent config_mutation commands auto-run. Without this gate, the
|
||||
// agent queues N individual approvals before the operator can respond,
|
||||
// flooding the chat with approval cards — confirmed in session 20757eb9
|
||||
// (WhatsApp bridge: two approvals for what should have been one plan).
|
||||
if riskClass == policy.RiskConfigMutation && sessionID != "" && !assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
var anyPending int
|
||||
pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM nomos_plan_executions pe
|
||||
JOIN executions ex ON ex.entity_id = pe.execution_id
|
||||
WHERE pe.session_id = $1 AND ex.status = 'pending_approval'`,
|
||||
sessionID).Scan(&anyPending)
|
||||
if anyPending > 0 {
|
||||
return textResult("An approval is already pending for this plan. Present the plan and its steps to the operator, then STOP and wait for their approval (\"approved\", \"yes\", \"go ahead\"). Do not call run again until the operator responds — after approval, all config_mutation commands will auto-run.")
|
||||
}
|
||||
}
|
||||
|
||||
id, _ := uuid.NewV7()
|
||||
correlationID := uuid.New().String()
|
||||
execName := "run on " + targetSlug + " (" + id.String() + ")"
|
||||
@@ -1484,7 +1363,11 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
// re-approval. This is the "approve the plan, carry it out" path — the
|
||||
// operator approved the overall direction; individual config steps
|
||||
// within the window don't each need a separate yes. Destructive
|
||||
// commands never auto-run, regardless of window.
|
||||
// commands never auto-run, regardless of window. (The old plan-window
|
||||
// path that opened on set_goal/propose_plan was removed — it opened
|
||||
// before approval, letting config_mutation auto-run with zero operator
|
||||
// consent. The assent window, opened only on operator approval, is the
|
||||
// sole gate for config_mutation auto-run.)
|
||||
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
||||
if rerr != nil {
|
||||
@@ -1574,6 +1457,34 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac
|
||||
}
|
||||
}
|
||||
|
||||
// planWindowActive was removed 2026-07-15: it opened on set_goal and
|
||||
// propose_plan, letting config_mutation auto-run before operator approval.
|
||||
// The assent window (opened only on approval in agent.go) is the sole gate
|
||||
// for config_mutation auto-run now. See sessionHasPlan for the plan-existence
|
||||
// check used by the P1 plan-first gate.
|
||||
|
||||
// sessionHasPlan reports whether this nomos session has any plan step on
|
||||
// record (any generation, any status). Used by the P1 plan-first gate in
|
||||
// classifyAndGate to refuse `run` before `propose_plan` has been called.
|
||||
// A `replaced` step (from a prior plan generation that was superseded by a
|
||||
// follow-up sub-task — see store.reopenSession) still counts: it proves the
|
||||
// agent once framed a plan for this session, and the reopen path guarantees a
|
||||
// fresh `propose_plan` will run before the next `run` anyway. Fails closed
|
||||
// (returns true) when the query errors so a transient DB issue doesn't block
|
||||
// an otherwise-valid run.
|
||||
func sessionHasPlan(ctx context.Context, pool *db.Pool, sessionID string) bool {
|
||||
if sessionID == "" {
|
||||
return true // no session → no gate (direct MCP call from a script)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM session_plan_steps WHERE session_id = $1`,
|
||||
sessionID).Scan(&count); err != nil {
|
||||
return true // fail open on DB error — don't block work over a flake
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// assentWindowActive checks whether the operator has recently approved a plan
|
||||
// in THIS TASK's chat session. The agent sets an
|
||||
// assent_window.agent:<uuid>.session:<id> key in autonomy_settings with an
|
||||
@@ -1657,10 +1568,26 @@ func knowledgeSlug(kind, title string) string {
|
||||
func upsertKnowledge(ctx context.Context, pool *db.Pool, args map[string]any) (*mcp.CallToolResult, error) {
|
||||
title, _ := args["title"].(string)
|
||||
content, _ := args["content"].(string)
|
||||
about, _ := args["about"].(string)
|
||||
tagsRaw, _ := args["tags"].(string)
|
||||
kind, _ := args["kind"].(string)
|
||||
|
||||
// Normalize about: accept a single string slug or an array of slugs.
|
||||
var aboutSlugs []string
|
||||
switch v := args["about"].(type) {
|
||||
case string:
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
aboutSlugs = []string{s}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
aboutSlugs = append(aboutSlugs, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
title = strings.TrimSpace(title)
|
||||
content = strings.TrimSpace(content)
|
||||
if title == "" || content == "" {
|
||||
@@ -1707,11 +1634,13 @@ func upsertKnowledge(ctx context.Context, pool *db.Pool, args map[string]any) (*
|
||||
return textResult(fmt.Sprintf("error writing knowledge: %v", err)), nil
|
||||
}
|
||||
|
||||
// Link it to the entity it's about, if given and not already linked.
|
||||
// Link it to the entity(s) it's about, if given and not already linked.
|
||||
linked := ""
|
||||
if about = strings.TrimSpace(about); about != "" {
|
||||
if len(aboutSlugs) > 0 {
|
||||
var linkedSlugs []string
|
||||
for _, slug := range aboutSlugs {
|
||||
var targetID uuid.UUID
|
||||
if qerr := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", about).Scan(&targetID); qerr == nil {
|
||||
if qerr := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&targetID); qerr == nil {
|
||||
pool.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now()
|
||||
@@ -1719,9 +1648,13 @@ func upsertKnowledge(ctx context.Context, pool *db.Pool, args map[string]any) (*
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`,
|
||||
docID, targetID)
|
||||
linked = " and linked to " + about
|
||||
} else {
|
||||
linked = fmt.Sprintf(" (note: entity %q not found, saved unlinked)", about)
|
||||
linkedSlugs = append(linkedSlugs, slug)
|
||||
}
|
||||
}
|
||||
if len(linkedSlugs) == 1 {
|
||||
linked = " and linked to " + linkedSlugs[0]
|
||||
} else if len(linkedSlugs) > 1 {
|
||||
linked = fmt.Sprintf(" and linked to %d entities", len(linkedSlugs))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,11 +69,14 @@ var destructivePatterns = []*regexp.Regexp{
|
||||
var readOnlyLeadPattern = regexp.MustCompile(
|
||||
`^(cat|less|head|tail|ls|stat|file|du|df|free|uptime|uname|hostname|whoami|id|ip|ss|netstat|ping|` +
|
||||
`journalctl|dmesg|ps|top|htop|env|printenv|echo|which|whereis|` +
|
||||
`grep|egrep|fgrep|rg|wc|sort|uniq|cut|tr|tee|` +
|
||||
`grep|egrep|fgrep|rg|wc|sort|uniq|cut|tr|tee|find|tree|locate|` +
|
||||
`dpkg\s+(-l|-s|--list|--status)\b|apt\s+(list|search|show)\b|` +
|
||||
`systemctl\s+(status|is-active|is-enabled|is-failed|list-units)|` +
|
||||
`systemctl\s+(status|is-active|is-enabled|is-failed|list-units|list-unit-files|list-timers|show)\b|` +
|
||||
`timedatectl|hostnamectl|systemd-analyze|` +
|
||||
`docker\s+(ps|images|inspect|logs|version|info|stats)|` +
|
||||
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` +
|
||||
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
|
||||
`rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` +
|
||||
`git\s+(status|log|diff|show|branch|remote)|` +
|
||||
`curl\s+-.*-I\b|curl\s+.*--head\b)\b`)
|
||||
|
||||
|
||||
@@ -15,6 +15,23 @@ func TestClassifyCommand_ReadOnly(t *testing.T) {
|
||||
"git status",
|
||||
"sudo cat /var/log/syslog",
|
||||
"ip a",
|
||||
// P4: newly added read-only verbs.
|
||||
"find /var/log/rclone-backup/ -name runs.jsonl",
|
||||
"tree /etc/caddy",
|
||||
"locate Caddyfile",
|
||||
"systemctl list-timers --all",
|
||||
"systemctl list-units --type=service",
|
||||
"systemctl list-unit-files --state=enabled",
|
||||
"systemctl show caddy",
|
||||
"timedatectl",
|
||||
"hostnamectl",
|
||||
"systemd-analyze blame",
|
||||
"rclone lsl proton:library-backup",
|
||||
// docker compose read-only subcommands (F1 fix).
|
||||
"docker compose logs --tail=100",
|
||||
"docker compose ps",
|
||||
"docker compose top",
|
||||
"docker compose config",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyCommand(c, ""); got != RiskReadOnly {
|
||||
@@ -99,6 +116,9 @@ func TestClassifyCommand_CompoundReadOnly(t *testing.T) {
|
||||
"docker ps | grep caddy",
|
||||
"systemctl status caddy 2>&1; journalctl -u caddy -n 5 --no-pager",
|
||||
"sudo systemctl status caddy; sudo journalctl -u caddy -n 5",
|
||||
// P4: the exact compound from session d0d562e0 — find + ls + tail +
|
||||
// echo + journalctl, all read-only segments.
|
||||
"ls -lt /var/log/rclone-backup/ | head -20 && tail -3 /var/log/rclone-backup/runs.jsonl || echo \"not found\" && find /var/log/rclone-backup/ -name 'runs.jsonl'",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyCommand(c, ""); got != RiskReadOnly {
|
||||
|
||||
17
migrations/020_session_reliability.up.sql
Normal file
17
migrations/020_session_reliability.up.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 020_session_reliability.up.sql
|
||||
-- Plan step generation tracking + audit log session linkage.
|
||||
-- See plans/2026-07-14-session-reliability-and-ux-audit.md.
|
||||
|
||||
-- Plan step generation: when the agent revises a plan mid-flight, new steps
|
||||
-- get a higher generation number so the frontend can group/collapse old ones.
|
||||
ALTER TABLE session_plan_steps ADD COLUMN IF NOT EXISTS generation INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- Track which session produced each audit-log entry so per-session analysis
|
||||
-- (e.g. "did this task call update_entity_attributes?") is O(1) instead of
|
||||
-- scanning the full log.
|
||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS session_id UUID;
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_session ON audit_log (session_id);
|
||||
|
||||
-- Efficient lookup of pending-approval executions by session.
|
||||
CREATE INDEX IF NOT EXISTS idx_nomos_plan_executions_session
|
||||
ON nomos_plan_executions (session_id) WHERE continued_at IS NULL;
|
||||
159
nomos/SOUL.md
159
nomos/SOUL.md
@@ -3,6 +3,81 @@
|
||||
You are **Nomos** (from *oikonomos*, the steward of the oikos), the homelab
|
||||
AI agent running in a Docker container on mac-mini. You operate on port 8092.
|
||||
|
||||
## ⚠️ MANDATORY TASK FLOW — EVERY CHAT, NO EXCEPTIONS
|
||||
|
||||
You MUST follow this flow for EVERY user request. Skipping steps means 23
|
||||
individual approval popups instead of one plan approval. Do not skip.
|
||||
|
||||
### 1. SET GOAL — `set_goal`
|
||||
State what this task is trying to achieve in one sentence. Call this FIRST.
|
||||
Examples: "Audit all LXCs for pending apt updates" or "Deploy immich on strong."
|
||||
|
||||
### 2. PRE-PLAN — gather information
|
||||
Call ONLY read-only tools to understand what you're working with:
|
||||
- `search_knowledge` + `get_entity_knowledge` — has a past task already solved this?
|
||||
**Check the knowledge base BEFORE re-running fleet-wide work.** If a same-day
|
||||
or recent knowledge entry answers the question, present it and propose a
|
||||
refresh plan that touches only the high-risk targets — not the whole fleet.
|
||||
Re-running `run` against every LXC when the answer is already in the knowledge
|
||||
graph wastes executions and credits.
|
||||
- `get_entity` / `list_lxcs(state="active")` / `get_health_summary` — current state
|
||||
- `get_relations` + `get_blast_radius` — what depends on what
|
||||
Do NOT call `run` during this phase. This is research, not execution.
|
||||
|
||||
### 3. PROPOSE PLAN — `propose_plan`
|
||||
Call ONCE with EVERY step end-to-end. The LAST step MUST be:
|
||||
"Write back: update_entity_attributes + create_relationship + upsert_knowledge"
|
||||
Include target slugs on each step so the panel links them. If you omit the
|
||||
writeback step, one is auto-appended.
|
||||
|
||||
### 4. GET APPROVAL — only if the plan has config_mutation/destructive steps
|
||||
After proposing the plan, check the step risk classes:
|
||||
- **All read-only plan?** No approval needed. Go straight to step 5 and
|
||||
execute — read-only `run` commands auto-run immediately once a plan
|
||||
exists. Do NOT stop and wait.
|
||||
- **Any config_mutation or destructive step?** END YOUR TURN. Do not call
|
||||
`run`. Wait for the operator to approve. Approval vocabulary: "approved",
|
||||
"yes", "go", "proceed", "continue", "ok", "go ahead". The assent window
|
||||
then auto-approves subsequent config_mutation commands.
|
||||
|
||||
### 5. EXECUTE — `run` calls
|
||||
Advance each step with `update_plan_step` (running → done) + `run`. Do NOT
|
||||
call `propose_plan` again — it is refused once a step has started.
|
||||
Read-only commands auto-run (no approval). Config_mutation commands
|
||||
auto-run under the assent window (after approval). Destructive commands
|
||||
always need explicit typed confirmation.
|
||||
|
||||
### 6. WRITE BACK + COMPLETE — `complete_task`
|
||||
Call `update_entity_attributes` for every entity you ran `run` against
|
||||
(versions, states, counts, timestamps). Call `create_relationship` for any
|
||||
edge you discovered. Then `upsert_knowledge` for the narrative (pass `about`
|
||||
as an array of entity slugs). Then `complete_task` with the outcome.
|
||||
`complete_task` with `outcome=success` is **REFUSED** if you ran `run` but
|
||||
didn't call `update_entity_attributes`/`create_relationship` — the knowledge
|
||||
graph drifts without writeback. The ONLY carve-out from the writeback gate
|
||||
is a pure-DB Q&A that called *no* `run` at all (only get_entity/list_lxcs/
|
||||
search_knowledge): answer directly, `complete_task` with a one-line summary,
|
||||
no writeback needed.
|
||||
|
||||
### 7. ITERATE — follow-ups reopen the task
|
||||
A `complete_task` is not the end of the conversation. If the operator sends
|
||||
a follow-up on a completed session — e.g. "now look into the X you flagged"
|
||||
or "fix that" — the session is reopened (status flips back to `executing`,
|
||||
the prior plan is marked `replaced`). Treat the follow-up as a NEW sub-task:
|
||||
call `set_goal` with the new goal, `propose_plan` a fresh plan (a new
|
||||
generation — the panel will show it as a new list), execute, write back,
|
||||
`complete_task`. Do NOT re-open or re-advance the old plan's steps.
|
||||
|
||||
**Anti-patterns (DO NOT DO):**
|
||||
- Call `run` 23 times without `propose_plan` → 23 individual approval popups.
|
||||
- Call `propose_plan` again after a step has started → refused; advance with
|
||||
`update_plan_step` + `run` instead.
|
||||
- Re-execute work when the operator points out a UI/sidebar inconsistency →
|
||||
fix the display with `update_plan_step` (reconcile step states) or summarize
|
||||
the panel in your reply. Never re-run `run` just to fix a display mismatch.
|
||||
- Re-run a fleet-wide audit when a same-day knowledge entry already has the
|
||||
answer → present the existing knowledge, propose a targeted refresh only.
|
||||
|
||||
## Source of truth
|
||||
|
||||
The Oikos DB is the authoritative source for topology, service state, policy,
|
||||
@@ -46,67 +121,23 @@ classifier will catch a genuinely dangerous command regardless, but be honest
|
||||
about risk in your `purpose` text; the operator is trusting your description
|
||||
of what a command does.
|
||||
|
||||
## Every chat is a task
|
||||
## Every chat is a task — and every task has a plan
|
||||
|
||||
Each conversation is a **task**: a goal the operator wants achieved, from
|
||||
"install service X" to "give me the key status of Y". Every non-trivial task
|
||||
has the SAME first step and the SAME last step — research in, knowledge out —
|
||||
so the graph never drifts from reality and every task makes the next one
|
||||
smarter. Make both of these literal entries in the plan you propose, not just
|
||||
things you do quietly in the background:
|
||||
Every non-trivial chat follows the MANDATORY TASK FLOW at the top of this
|
||||
file. **`propose_plan` is mandatory for any task that calls `run`** — even a
|
||||
read-only inspection question needs a one-step plan ("Inspect X, report,
|
||||
write back"). The `run` handler enforces this structurally: it refuses to
|
||||
execute without a plan on record. A one-step plan is fine for trivial
|
||||
questions; the point is that the operator sees what you intend before you
|
||||
touch a target, not that every question needs a 10-step ceremony.
|
||||
|
||||
1. **FIRST STEP, ALWAYS: gather knowledge, not just the target's current
|
||||
status.** Before proposing the rest of the plan, build the full picture of
|
||||
what you're working with:
|
||||
- `get_entity` / `explain` — what the entity actually is right now.
|
||||
- `get_entity_knowledge` + `search_knowledge` — has a past task already
|
||||
solved this, hit this gotcha, or failed trying something? This is how
|
||||
tasks compound: each one's recorded outcome becomes the next one's prior.
|
||||
Don't skip it and rediscover a known problem.
|
||||
- `get_relations` + `get_blast_radius` — what depends on this, what does
|
||||
this depend on, what breaks if it changes. Never plan a mutation blind to
|
||||
its neighborhood.
|
||||
- `http_get` — for anything involving an external service/repo, read its
|
||||
docs/README before proposing how to deploy or configure it.
|
||||
This is real plan work, not throat-clearing — make it step 1 in
|
||||
`propose_plan` (e.g. "Research lxc:caddy — prior knowledge, relations,
|
||||
blast radius") so the operator sees it happened, not just its results.
|
||||
2. **Plan, then execute.** With that context in hand, call `propose_plan` ONCE
|
||||
with the COMPLETE ordered list of every step end-to-end — not one call per
|
||||
step. The operator watches this list in the context panel; if you call
|
||||
`propose_plan` again for each step as you go, each call replaces what they
|
||||
see with just that one step, and the plan looks like it's stuck at "1/1"
|
||||
forever instead of showing real progress. Get the single approval, then
|
||||
carry the whole plan out end-to-end, advancing steps with
|
||||
`update_plan_step` (see the plan/approval sections below). If you hit a
|
||||
genuine decision only the operator can make — an ambiguous target, a
|
||||
trade-off, missing information — call `ask_operator` with the options and
|
||||
the entities involved, then STOP and wait; their answer resumes you. Don't
|
||||
ask about things you can settle yourself with tools.
|
||||
3. **LAST STEP, ALWAYS: update the knowledge base before `complete_task`, not
|
||||
after.** Make this the final step in the plan, and actually do it — this is
|
||||
what prevents the graph from drifting away from reality:
|
||||
- `update_entity_attributes` — any concrete fact you discovered about an
|
||||
entity's real state that the graph didn't have (an IP, a version, a
|
||||
config value, a discovered port). Future tasks read entities, not your
|
||||
transcript — if it's not written back, it's lost.
|
||||
- `create_relationship` — any dependency/edge you discovered that wasn't
|
||||
already in the graph (hosts, depends-on, provides, ...).
|
||||
- `upsert_knowledge` — the narrative: what you learned, the fix, the
|
||||
gotcha, `about` the relevant entity. A failed task is worth recording
|
||||
too: "tried X on Z, it failed because W" saves the next attempt. A chat
|
||||
message alone is forgotten; this is the only thing a future task's step 1
|
||||
can retrieve.
|
||||
Then `complete_task` with the `outcome` (success/failure/partial) and a
|
||||
one-line `summary`. A task that just trails off never gets a real outcome,
|
||||
and one that completes without writing back what changed leaves the next
|
||||
task to rediscover it from scratch.
|
||||
The ONLY carve-out is a pure-DB Q&A that calls *no* `run` (only
|
||||
get_entity / list_lxcs / search_knowledge / get_relations / etc.): answer
|
||||
directly and `complete_task` with a one-line summary. Don't invent
|
||||
attributes/relationships/knowledge that don't exist just to fill the step.
|
||||
|
||||
A trivial read-only task ("what's the status of Y?") is a degenerate case:
|
||||
research is just the lookup itself, there's usually nothing new to write back,
|
||||
and no plan/approval ceremony is needed — answer it and `complete_task` with a
|
||||
one-line summary. Don't invent attributes/relationships/knowledge that don't
|
||||
exist just to fill the step. The loop scales down; it doesn't disappear.
|
||||
The loop scales down (one-step plan for a trivial question) — it doesn't
|
||||
disappear.
|
||||
|
||||
## Key MCP tools
|
||||
|
||||
@@ -125,10 +156,10 @@ exist just to fill the step. The loop scales down; it doesn't disappear.
|
||||
can be multi-line), `purpose` (one sentence — the operator sees exactly this when
|
||||
deciding). Auto-runs if read-only; otherwise queues for approval. See "Your
|
||||
capability is unlimited" above.
|
||||
- `request_execution` — curated fast-paths for common named actions: restart, systemctl
|
||||
(enable/disable/reload), pct_exec (shell command inside an existing LXC), apt_upgrade
|
||||
(audit/upgrade), pct_create (provision a new LXC). Use these when they fit; use `run`
|
||||
for everything else — you do not need a matching named action to act.
|
||||
- `run` — the ONLY mutation tool. Accepts `target`, `command`, `purpose`,
|
||||
`declared_risk`. The `request_execution` fixed-enum tool is RETIRED
|
||||
(2026-07-14) — use `run` for EVERYTHING: restarts, apt upgrades, pct exec,
|
||||
pct create, any shell command. There is no named-action tool anymore.
|
||||
- `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text.
|
||||
You CAN read the internet with this. When asked to deploy a service from a URL or repo,
|
||||
call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its
|
||||
@@ -158,7 +189,7 @@ exist just to fill the step. The loop scales down; it doesn't disappear.
|
||||
|
||||
## Policy awareness
|
||||
|
||||
Before calling `request_execution`:
|
||||
Before calling `run`:
|
||||
- Check risk class via `get_entity` on the target
|
||||
- `pct_create` — `config_mutation`: **ATOMIC** — creates and starts a new LXC, nothing
|
||||
more. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new container
|
||||
@@ -233,7 +264,7 @@ note — continue executing the full plan from there. Do not re-request the same
|
||||
action; check `get_execution_status` if you need the outcome. One approval per
|
||||
action is enough.
|
||||
|
||||
**When proposing a plan, ALWAYS call `request_execution`/`run` in the same
|
||||
**When proposing a plan, ALWAYS call `run` in the same
|
||||
turn.** Do not propose a plan in text, ask "shall I proceed?", and wait.
|
||||
Call the tool — if it queues for approval, present what's queued and stop.
|
||||
The operator's "proceed"/"go ahead" will grant it and open the assent window.
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
## Overview
|
||||
|
||||
Standard operating procedures for the Nomos agent managing the hubris
|
||||
homelab. All mutations route through `request_execution` → Oikos policy
|
||||
gating → actuator (SSH).
|
||||
homelab. All mutations route through `run` → Oikos policy
|
||||
gating → actuator (SSH). The `request_execution` fixed-enum tool was retired
|
||||
2026-07-14.
|
||||
|
||||
## Procedures
|
||||
|
||||
@@ -22,13 +23,13 @@ gating → actuator (SSH).
|
||||
|
||||
### Signal response
|
||||
|
||||
- `reversible_low` with validated pattern → `request_execution` (auto-restart)
|
||||
- `reversible_low` with validated pattern → `run` (auto-restart)
|
||||
- `config_mutation` or `destructive` → escalate to operator
|
||||
- Repeated flapping → escalate with flap count
|
||||
|
||||
### Execution tracking
|
||||
|
||||
1. `request_execution` returns a correlation_id
|
||||
1. `run` returns the execution ID in its result text
|
||||
2. Poll `get_event_timeline` filtering by correlation_id
|
||||
3. Once complete, `get_health_summary` to verify recovery
|
||||
4. Record outcome via internal reasoning
|
||||
@@ -41,7 +42,9 @@ gating → actuator (SSH).
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-07-08 — rename to Nomos
|
||||
### 2026-07-14 — request_execution retired
|
||||
All references to `request_execution` replaced with `run`. The fixed-enum
|
||||
tool is no longer registered; agents use `run` for all mutations.
|
||||
Agent renamed from Hermes to Nomos (N0 milestone).
|
||||
|
||||
### 2026-07-07 — initial Phase 4 skill
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
# 2026-07-10 — General gated execution: from fixed actions to unlimited-but-gated
|
||||
|
||||
**Status:** In Progress — audited 2026-07-11. Done: `ClassifyCommand` risk
|
||||
**Status:** In Progress — re-audited 2026-07-14. Done: `ClassifyCommand` risk
|
||||
classifier, general `run` MCP tool, chat-assent approval (no button
|
||||
required), blast radius on approval cards, session digest, global activity
|
||||
feed (`Ops.svelte` "Executions" tab, risk-badged), Learning view
|
||||
(success-rate trend). Still open: retire the fixed `request_execution`
|
||||
action enum (`restart, systemctl, pct_exec, apt_upgrade, pct_create` still
|
||||
hard-coded alongside `run`), and revive auto-act — `internal/actuator/actuator.go:125`
|
||||
is still a literal `{"success": true, "message": "stub execution"}` stub.
|
||||
(success-rate trend), **and now the `request_execution` enum retirement**
|
||||
(commit `60effcb`, 2026-07-14 — `run` is the only mutation tool; the legacy
|
||||
handler functions are kept as reference only, with a "DO NOT re-register"
|
||||
guard in `internal/mcp/server.go:360`). Still open: **revive auto-act** —
|
||||
`internal/actuator/actuator.go:~125` is still a literal
|
||||
`{"success": true, "message": "stub execution"}` stub (item 10). The
|
||||
`run`-gated path covers operator-initiated work end-to-end; auto-act is the
|
||||
observe→Act direction (signals triggering actions), still unimplemented.
|
||||
|
||||
## Goal
|
||||
|
||||
|
||||
159
plans/2026-07-14-activity-gaps.md
Normal file
159
plans/2026-07-14-activity-gaps.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# 2026-07-14 — Session review + Activity timeline refinements
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
## Session analysis: `722d8878` (2026-07-14T10:45)
|
||||
|
||||
"Fleet-wide audit: check all services, identify what needs updating"
|
||||
|
||||
### What happened
|
||||
- User asked for fleet audit → agent called `set_goal` (session → executing)
|
||||
- 82 tool calls in ONE turn: 58 `run`, 8 `get_entity`, 4 `get_relations`, etc.
|
||||
- **No `propose_plan`** — no plan steps, entered executing directly
|
||||
- **No `complete_task`** — agent produced a final text response but never closed the task
|
||||
- Session stuck at `executing` with a text conclusion but no terminal state
|
||||
|
||||
### Gaps found
|
||||
|
||||
| # | Issue | Root cause |
|
||||
|---|---|---|
|
||||
| 1 | "Agent is thinking" stuck at end | `liveStatus === 'executing'` is true even after the turn ends. AgentIndicator shows generic message because there are no running activity entries to describe. |
|
||||
| 2 | No plan proposed | Model skipped `propose_plan` — possibly because it found prior knowledge and skipped to execution. `setGoal` now sets `executing` directly (our fix), which makes `propose_plan` optional — but the sidebar "Plan" section shows "No plan yet" permanently. |
|
||||
| 3 | Task never completed | Model produced final text but never called `complete_task`. The idle sweep (2 min now) will nudge, then auto-close. |
|
||||
| 4 | Approvals invisible in Activity | 58 `run` calls, many requiring approval. These show in chat via InlineApproval but NOT in the Activity timeline. The operator has to switch to Ops page to track approvals. |
|
||||
| 5 | Activity is newest-first | Timeline shows newest at top. Feels unnatural for a sequential log — bottom-scrolling with newest at bottom is more intuitive for "watching" what the agent does. |
|
||||
| 6 | Knowledge recorded not shown | The agent recorded knowledge but it doesn't appear in Activity if it came from prior knowledge entries via `get_knowledge_content`. |
|
||||
|
||||
---
|
||||
|
||||
## Plan
|
||||
|
||||
### 1. Fix "Agent is thinking" stuck indicator
|
||||
|
||||
**Root cause:** AgentIndicator shows when `active={$streaming || liveStatus === 'executing'}`. After the turn completes, `liveStatus` is still `'executing'` and there are no running entries, so the label falls through to the generic `"Agent is thinking…"` fallback.
|
||||
|
||||
**Fix:** Change the active condition to only show when there's actual work:
|
||||
```ts
|
||||
active={$streaming || $activityLog.some((e) => e.status === 'running')}
|
||||
```
|
||||
This way it shows during streaming AND when there are running tools (auto-continuation), but NOT when the session is just "executing" with no active work.
|
||||
|
||||
Also: when the last assistant message has text AND no pending tool calls, auto-hide the indicator. The `liveStatus === 'executing'` check is too broad — it covers the entire session lifetime.
|
||||
|
||||
### 2. Approvals in Activity timeline
|
||||
|
||||
**What:** InlineApproval cards show in chat but not in Activity. Every `run` that queues an execution with "requires approval" should appear as an entry in the Activity timeline.
|
||||
|
||||
**How:**
|
||||
- In `activity.ts`, detect tool results containing "requires approval" + execution ID
|
||||
- Add `type: 'approval_pending'` entries with the execution ID, target, action, and status
|
||||
- Poll execution status and update the entry (pending → approved → running → completed/failed)
|
||||
- The InlineApproval component stays in chat for the Approve/Deny buttons
|
||||
- Activity shows the full lifecycle: approval requested → approved → running → done
|
||||
|
||||
### 3. Old-to-new ordering
|
||||
|
||||
**Fix:** Remove `.sort((a, b) => b.timestamp - a.timestamp)` → change to `.sort((a, b) => a.timestamp - b.timestamp)` or no sort at all (entries are already added in chronological order).
|
||||
|
||||
This means the timeline reads top-to-bottom as the session unfolds. Currently `newest at top` means the "Goal" and "Plan" entries appear at the bottom, which is confusing.
|
||||
|
||||
### 4. Knowledge detection
|
||||
|
||||
**Fix:** Extend the knowledge detection in `activity.ts` to also catch `upsert_knowledge` calls from `tool_use` events (not just `tool_result`), so the entry appears as "running" while recording and then "done" when the result comes back.
|
||||
|
||||
### 5. Auto-hide indicator when turn ends with text
|
||||
|
||||
**Fix:** Detect when the last assistant message has text content AND there are no pending tool_use entries without matching tool_result. In that case, the turn is complete — don't show the indicator.
|
||||
|
||||
---
|
||||
|
||||
## Plan-approve-once (new policy)
|
||||
|
||||
### Problem
|
||||
|
||||
Today: agent calls `propose_plan` + `run` × 10 in the same turn. Each `run`
|
||||
queues an individual approval. Operator sees 10 "requires approval" cards.
|
||||
After operator types "yes", each one is individually approved, THEN the
|
||||
assent window opens and future calls auto-run.
|
||||
|
||||
The operator shouldn't see per-action approvals when they already approved
|
||||
the plan. The plan IS the approval. Individual actions within an approved
|
||||
plan should auto-execute.
|
||||
|
||||
### Target
|
||||
|
||||
```
|
||||
User: "audit fleet"
|
||||
Agent: "Here's my plan: 1. List LXCs 2. Check apt on each 3. Report" ← proposes plan
|
||||
[Proposed plan: 3 steps] [Approve plan?]
|
||||
User: "approved"
|
||||
Agent: ◉ Listing containers… ← auto-runs
|
||||
◉ Checking apt on lxc:jellyfin… ← auto-runs
|
||||
...
|
||||
"Done. 19 LXCs have pending updates."
|
||||
```
|
||||
|
||||
One approval for the plan. All actions within it auto-execute. No per-action
|
||||
approval cards. Only re-approve when the agent calls `propose_plan` again
|
||||
(significant plan change).
|
||||
|
||||
### How (server-side)
|
||||
|
||||
The classification logic in `internal/mcp/server.go:run()` needs to know whether
|
||||
a plan-approval-assent-window is active for this session. Currently it checks
|
||||
`autonomy_settings` for the assent window key. The change: when `propose_plan`
|
||||
is called, pre-activate the window with a "plan-proposed" state. When the
|
||||
operator approves, transition to "plan-active". `run` calls within an active
|
||||
plan window auto-execute at `config_mutation` level.
|
||||
|
||||
Key change in `store.go:proposePlan()`:
|
||||
```go
|
||||
// Pre-record a plan-proposed window so that run calls know a plan is pending approval.
|
||||
// Once approved, this becomes the full assent window.
|
||||
key := planWindowKey(agentID, sessionID)
|
||||
s.pool.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, 'proposed')
|
||||
ON CONFLICT (key) DO UPDATE SET value = 'proposed'`, key)
|
||||
```
|
||||
|
||||
Then in the `run` handler, check for `plan-active` OR `assent-active`:
|
||||
```go
|
||||
// If a plan window is active, this run call is part of the approved plan
|
||||
// and config_mutation commands auto-execute without individual approval.
|
||||
if planWindowActive(ctx, agentID, sessionID) { ... }
|
||||
```
|
||||
|
||||
### How (frontend)
|
||||
|
||||
- Instead of 10 individual InlineApproval cards, show ONE "Approve plan?" card
|
||||
- When approved, all queued `run` calls from the plan turn auto-grant
|
||||
- Activity timeline shows plan approval as one entry: "✓ Plan approved — 12 actions"
|
||||
- Subsequent `run` calls show with an "auto (plan)" badge instead of approval cards
|
||||
|
||||
---
|
||||
|
||||
## Implementation plan (consolidated)
|
||||
|
||||
### Phase A — Quick fixes (today)
|
||||
|
||||
| # | Fix |
|
||||
|---|---|
|
||||
| A1 | AgentIndicator: only show when `$streaming || hasRunningActivity` (not on `liveStatus === 'executing'`) |
|
||||
| A2 | Activity timeline: old-to-new ordering |
|
||||
| A3 | Knowledge entries: detect `tool_use` events for running state |
|
||||
|
||||
### Phase B — Approvals in Activity
|
||||
|
||||
| # | Fix |
|
||||
|---|---|
|
||||
| B1 | `activityLog`: add `approval_pending` / `approval_granted` / `execution_running` / `execution_done` lifecycle entries |
|
||||
| B2 | Poll execution status and update Activity entries inline |
|
||||
| B3 | InlineApproval stays in chat (needs operator action), but lifecycle tracked in Activity |
|
||||
|
||||
### Phase C — Plan-approve-once (policy change)
|
||||
|
||||
| # | Fix |
|
||||
|---|---|
|
||||
| C1 | Backend: plan window in `autonomy_settings`, checked by `run` handler |
|
||||
| C2 | Frontend: single "Approve plan?" card instead of per-action cards |
|
||||
| C3 | Backend: when plan is approved, auto-grant all pending executions from the plan turn |
|
||||
| C4 | Activity: plan approval as single timeline entry with action count badge |
|
||||
133
plans/2026-07-14-activity-timeline.md
Normal file
133
plans/2026-07-14-activity-timeline.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 2026-07-14 — Unified sidebar activity timeline
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
## Current state (broken)
|
||||
|
||||
The sidebar has three sections that appear/disappear independently:
|
||||
|
||||
| Section | When visible | Shows |
|
||||
|---|---|---|
|
||||
| Plan (top) | When `$planSteps.length > 0` | Plan step names with progress bar |
|
||||
| Tool activity (middle) | When `toolCount > 0` | Compact tool list, grouped by turn |
|
||||
| This session (bottom) | When `digest.total_executions > 0` | Post-hoc execution count + knowledge |
|
||||
|
||||
State changes cause sections to **pop in/out** as the agent moves between
|
||||
planning → executing → done. The "0 tools · 0 running" counter flashes
|
||||
briefly then vanishes. Tool activity appears/disappears between turns.
|
||||
|
||||
## Target: single unified timeline
|
||||
|
||||
One section, always present when a session is loaded. Every agent action
|
||||
appears as an entry in reverse-chronological order (newest at top).
|
||||
|
||||
```
|
||||
┌─ Activity ───────────────────────── ─┐
|
||||
│ │
|
||||
│ ✓ Task completed: "Upgraded 4 LXCs" │ ← newest
|
||||
│ ◉ Running: apt upgrade on lxc:dns │
|
||||
│ ✓ run: apt upgrade on lxc:gitea │ ← tool completed
|
||||
│ ✓ Verified gitea: HTTP 200 │
|
||||
│ ◉ Step 3/5 — Upgrade dns │ ← plan step running
|
||||
│ ✓ Step 2/5 — Upgrade gitea │ ← plan step done
|
||||
│ ✓ run: apt upgrade on lxc:nfs-export │
|
||||
│ ◉ Step 1/5 — Upgrade nfs-export │
|
||||
│ ✓ Knowledge recorded │
|
||||
│ 📋 Plan set: 5 steps │ ← plan proposed
|
||||
│ 🎯 Goal: Upgrade 4 low-risk LXCs │ ← goal set
|
||||
│ │ ← oldest
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Entry types
|
||||
|
||||
| Type | Icon | Example description |
|
||||
|---|---|---|
|
||||
| `goal` | 🎯 | "Audit all LXCs for updates" |
|
||||
| `plan` | 📋 | "Plan set: 5 steps" |
|
||||
| `step_start` | ◉ spinner | "Step 2/5 — Upgrade gitea" |
|
||||
| `step_done` | ✓ | "Step 2/5 — Upgrade gitea" |
|
||||
| `tool_start` | ◉ spinner | "run: Upgrade nfs-export (21 pkgs)" |
|
||||
| `tool_done` | ✓ | "run: 0 upgraded, 0 newly installed" |
|
||||
| `tool_error` | ✗ | "run: SSH handshake failed" |
|
||||
| `knowledge` | ✨ | "Recorded: How to run fleet upgrades" |
|
||||
| `complete` | ✓ | "Task completed: success" |
|
||||
| `question` | ❓ | "Asked: Which host for the LXC?" |
|
||||
| `error` | ✗ | "Auto-resume failed: context deadline exceeded" |
|
||||
|
||||
### Data source
|
||||
|
||||
Entries come from all available sources, merged and deduplicated:
|
||||
1. **`toolTimeline` store** (live tool_use/tool_result pairs)
|
||||
2. **`planSteps` store** (step status transitions)
|
||||
3. **Session digest API** (knowledge created, final outcome)
|
||||
4. **`currentTask` store** (goal, status)
|
||||
|
||||
Deduplication: when a plan step links to a tool call via `execution_id`, show
|
||||
them as one entry instead of two (e.g. "Step 3: Upgrade dns ◉ running" includes
|
||||
the tool — don't show a separate "run: apt upgrade" entry).
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Always visible** when `$currentSession` is set
|
||||
- **Reverse chronological** — newest entries at top, scrolls naturally
|
||||
- **Auto-expands** the entry for the currently-running tool/step
|
||||
- **Collapses** completed entries to one line (expandable)
|
||||
- **Polls** every 3s for live updates (same as current startPolling)
|
||||
- **No flashing** — entries only change status in place (tool_start → tool_done), never removed
|
||||
- **Persists** across page navigation (rehydrated from REST on load)
|
||||
- **Empty state** when no session: "Open a session to see agent activity"
|
||||
|
||||
### What gets removed from chat
|
||||
|
||||
- **ToolCallGroup** — the compact tool counter. Tools live in the timeline now.
|
||||
- **AgentIndicator at bottom** — partially. Keep it ONLY for the initial
|
||||
"thinking" state (before any tools fire). Once the first tool fires, the
|
||||
timeline is the source of truth and the chat indicator is redundant.
|
||||
Actually: remove it entirely. The timeline IS the indicator.
|
||||
|
||||
### What stays in chat
|
||||
|
||||
- **Agent text responses** — the thinking, conclusions, reports
|
||||
- **InlineApproval cards** — approvals need operator action, must be in chat
|
||||
- **Inline tool renderers** — entity cards, health summary, etc. (informational)
|
||||
- **User messages** — obviously
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Data layer: `activityLog` derived store
|
||||
|
||||
Add to `chat.ts`:
|
||||
|
||||
```ts
|
||||
export interface ActivityEntry {
|
||||
id: string
|
||||
type: 'goal' | 'plan' | 'step_start' | 'step_done' | 'step_failed' |
|
||||
'tool_start' | 'tool_done' | 'tool_error' |
|
||||
'knowledge' | 'complete' | 'question' | 'error'
|
||||
description: string
|
||||
detail?: string // tool result text, step detail, etc.
|
||||
timestamp: number // Date.now() when created
|
||||
seq?: number // plan step seq, for ordering
|
||||
toolName?: string // for tool entries
|
||||
status: 'running' | 'done' | 'failed'
|
||||
collapsed: boolean // initial collapsed state (true for completed)
|
||||
}
|
||||
```
|
||||
|
||||
Derived reactively from `messages`, `planSteps`, `currentTask`, and session
|
||||
digest data. Uses `$derived.by()` to recompute when any source changes.
|
||||
|
||||
### 2. New component: `ActivityTimeline.svelte`
|
||||
|
||||
Replaces all three sidebar sections. Renders `activityLog` entries as a
|
||||
vertical timeline with connecting lines.
|
||||
|
||||
### 3. Remove from chat
|
||||
|
||||
- `<ToolCallGroup>` rendered in chat
|
||||
- `<AgentIndicator>` at bottom
|
||||
|
||||
### 4. Update TaskContextPanel
|
||||
|
||||
Replace PlanProgress + SessionDigest with ActivityTimeline.
|
||||
889
plans/2026-07-14-post-fix-session-remainders.md
Normal file
889
plans/2026-07-14-post-fix-session-remainders.md
Normal file
@@ -0,0 +1,889 @@
|
||||
# 2026-07-14 — Post-fix session audit: empty responses & plan drift remainders
|
||||
|
||||
**Status:** Done — 2026-07-14. All 18 fixes shipped, e2e-validated via the
|
||||
golden eval harness (4/4 passed), committed (`337d577` + `3de359b` +
|
||||
`dd3076a`), pushed to `main`, and deployed to `oikos-nomos-1` (v0.5.3). The
|
||||
knowledge loop is structurally closed, the plan-duplication chain is broken,
|
||||
and the eval harness catches regressions on future changes.
|
||||
|
||||
**PM addition — OIDC token-refresh fix** (lines 9-23 below) also shipped:
|
||||
committed as `3b98097` ("fix(web): refresh expired OIDC tokens before API
|
||||
calls"). The root cause of the empty-graph symptom is fixed and deployed.
|
||||
|
||||
**2026-07-14 (PM) — OIDC token-refresh fix (unplanned, root-cause for the
|
||||
empty graph symptom):** the overview background graph and the Knowledge Base
|
||||
graph both rendered empty because the SPA's OIDC access token expired
|
||||
(~5 min TTL) and was never refreshed. `fetchWithAuth` called `getToken()`
|
||||
synchronously (no refresh); `ensureToken` returned the stale token without
|
||||
refreshing; `storeTokens` discarded `expires_in`; and the resulting 401
|
||||
made `fetchGraph` return `null` → both graphs drew nothing, with no error
|
||||
surfaced. Fixed structurally in `web/src/lib/oidc.ts` +
|
||||
`web/src/lib/config.ts` + `web/src/lib/stores/events.ts`: tokens now carry
|
||||
`expiresAt`, `getToken()` returns null within 30s of expiry, `fetchWithAuth`
|
||||
awaits `ensureToken()` (refreshes on demand), `sseUrl` is async + refreshes
|
||||
before constructing the EventSource, and a 401 flushes the OIDC session so
|
||||
the static token fallback takes over. Build passes. Not yet committed or
|
||||
deployed (pending operator verification). Not part of any numbered phase
|
||||
above — filed here because it was the highest-impact surface symptom.
|
||||
|
||||
## Shipped (2026-07-14, v0.5.0–v0.5.3 — commits 337d577 + 3de359b + dd3076a, deployed)
|
||||
|
||||
| Fix | File(s) | Validation |
|
||||
|---|---|---|
|
||||
| **A.1** `proposePlan` sets `generation` on INSERT | `cmd/nomos/store.go` | eval: plan steps carry `generation: 1` |
|
||||
| **A.2** `proposePlan` refuses re-proposal when in flight (drops append-mode) | `cmd/nomos/store.go`, `cmd/nomos/tasks.go` | eval: `propose_plan` called exactly once on "proceed" |
|
||||
| **A.3** `propose_plan` tool description restated as a crisp contract | `cmd/nomos/tasks.go` | agent self-described the contract |
|
||||
| **F.3** Approval vocabulary expanded + directive result strings | `cmd/nomos/tasks.go` | eval: "proceed" and "go ahead" both recognized as approval |
|
||||
| **B.1** `chatWith` emits `done` after `error` on every terminal path | `cmd/nomos/agent.go` | eval: no reconnect/resume entries in nomos logs |
|
||||
| **B.2** Reconnect/resume note carries last user msg + plan-in-flight directive | `cmd/nomos/store.go`, `cmd/nomos/main.go`, `cmd/nomos/continue.go` | wired into all 4 resume entry points |
|
||||
| **B.3** `resumeSession` escalates the recovery note across 3 attempts | `cmd/nomos/continue.go` | e2e: escalated retry produced a real response |
|
||||
| **D.1** `complete_task` refused when discovery ran without writeback | `cmd/nomos/store.go`, `cmd/nomos/tasks.go` | e2e: agent REFUSED → wrote back → retried → succeeded |
|
||||
| **D.2** `propose_plan` auto-appends a writeback step if missing | `cmd/nomos/tasks.go` | e2e: appended step 4 when agent omitted writeback |
|
||||
| **F.1** Consolidated SOUL.md's three overlapping task-flow sections to one | `nomos/SOUL.md` | eval: agent follows the consolidated flow (4/4 evals pass) |
|
||||
| **F.2** Tightened set_goal/update_plan_step result strings to imperatives | `cmd/nomos/tasks.go` | eval: tool results are now directive |
|
||||
| **C.1** `completeTask` rejects re-completion of a terminal session | `cmd/nomos/store.go`, `cmd/nomos/tasks.go` | eval: `complete_task` called exactly once |
|
||||
| **C.2** SOUL.md: don't re-execute on UI-clarification complaints | `nomos/SOUL.md` | eval: no re-execution on followup |
|
||||
| **B.4** Surface real model error text (finish_reason + refusal) | `cmd/nomos/agent.go` | error event now carries `finish_reason=length` etc. |
|
||||
| **B.5** Back off between resume retries (4s, 8s) | `cmd/nomos/continue.go` | exponential backoff between attempts |
|
||||
| **B.6** Don't persist empty placeholder as a visible bubble | `cmd/nomos/main.go`, `cmd/nomos/store.go` | empty rows deleted, not persisted |
|
||||
| **E.1** SOUL.md: prefer knowledge over re-execution for fleet-wide facts | `nomos/SOUL.md` | eval: `search_knowledge` called first, 0 `run` calls on fleet audit |
|
||||
| **E.2** `list_lxcs` last-audited hint in the result | `internal/mcp/server.go` | `last_audited_at` column via `about` edge subquery |
|
||||
| **Bonus** Fixed pre-existing tool-call doubling bug in persistence | `cmd/nomos/main.go`, `cmd/nomos/continue.go` | eval: tool-call counts now accurate (was 2× in every session since v0.3.x) |
|
||||
|
||||
Tests: `TestProposePlan_RefuseInFlight` + `TestHadDiscoveryAndWriteback` in
|
||||
`cmd/nomos/store_test.go`. Golden eval harness: `cmd/nomos/eval/` with 4
|
||||
conversations in `cmd/nomos/eval/evals/golden.yaml` — all 4 pass.
|
||||
|
||||
## Golden eval results (v0.5.3, 4/4 passed)
|
||||
|
||||
| Eval | Tool calls | Key assertions |
|
||||
|---|---|---|
|
||||
| trivial_readonly | 2 | no plan, no run, completes |
|
||||
| plan_advances_on_proceed | 13 | propose_plan ×1, writes back, complete_task ×1 |
|
||||
| ui_complaint_no_rerun | 12 | propose_plan ×1, writes back |
|
||||
| knowledge_preferred_over_rerun | 7 | search_knowledge ×1, 0 run calls |
|
||||
|
||||
Run: `go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest cmd/nomos/eval/evals/*.yaml` (~$0.10/run).
|
||||
|
||||
## Remaining (not yet shipped)
|
||||
|
||||
None. All 18 fixes + the OIDC token-refresh fix (PM addition, `3b98097`)
|
||||
are shipped, committed, and deployed.
|
||||
|
||||
## Commit-history context (the 20-commit iteration)
|
||||
|
||||
Reviewing `git log` since the agent-task phases landed (be3ce76 → 5caf49b),
|
||||
the same problems recur because we keep fixing them with **SOUL.md prose +
|
||||
safety-net append logic** instead of structural gates:
|
||||
|
||||
- `5384499` (Jul 11) — "plan panel showed only the latest step" → fixed by
|
||||
making `proposePlan` APPEND when a step is in flight, so history is
|
||||
preserved even if the model re-proposes per step. **This is the source of
|
||||
the duplication the operator saw today.** The fix traded "lost progress"
|
||||
for "duplicate progress" — and the duplication is what's visible to the
|
||||
operator now.
|
||||
- `e30813a` / `532310b` (Jul 11) — "research-first / knowledge-write-back-
|
||||
last explicit steps" → added the FIRST/LAST step language to SOUL.md.
|
||||
Three commits later the warnings are still being ignored in production.
|
||||
- `5caf49b` (Jul 14, today) — "mandatory pre-plan flow" → another SOUL.md
|
||||
section at the top of the file, overlapping the existing "Every chat is a
|
||||
task" / "AFTER EVERY TASK: WRITE BACK" sections. The agent now has three
|
||||
overlapping sections telling it the same thing.
|
||||
- `60effcb` (Jul 14) — Phase 5 of the prior plan added the `generation`
|
||||
column, the `replaced` status, the frontend grouping, and the writeback
|
||||
warnings. The migration landed; the INSERT in `proposePlan` did not.
|
||||
|
||||
**The pattern:** every iteration adds another paragraph to SOUL.md and a
|
||||
safety net in the store layer. The agent still does the wrong thing
|
||||
because prose instructions are unreliable and the safety nets paper over
|
||||
the symptom instead of refusing the bad action. **This plan pivots to
|
||||
structural gates** — `proposePlan` and `completeTask` should refuse the
|
||||
calls that produce drift, not accommodate them.
|
||||
|
||||
## Sessions under audit
|
||||
|
||||
| Session | Time | Goal | Messages | Outcome | Real tool calls |
|
||||
|---|---|---|---|---|---|
|
||||
| `722d8878` (failure) | 10:45 | Fleet update audit | 3 | **failed** — empty response during auto-resume | 41 in turn 1 |
|
||||
| `d9cdcee1` (success w/ friction) | 11:44 | Same prompt (user retried) | 11 | success | 38 across 5 turns |
|
||||
|
||||
Both sessions are the same operator request: "Check all the services on the
|
||||
homelab and give me an overview of what needs updating, categorize by
|
||||
criticality." Cross-referencing them shows **where the prior fixes held vs.
|
||||
where they didn't.**
|
||||
|
||||
---
|
||||
|
||||
## What worked (preserve)
|
||||
|
||||
- **`upsert_knowledge` `about` array** (5.2 from prior plan) — the agent
|
||||
linked the audit to all affected LXCs in one call:
|
||||
`about: ["lxc:nextcloud","lxc:jellyfin","host:hubris", ...]`.
|
||||
- **`complete_task` writeback warning** (5.5) — fired correctly (the session
|
||||
has no `update_entity_attributes` calls and the warning text appears in the
|
||||
tool result).
|
||||
- **`propose_plan` writeback nudge** (5.4) — fired (last step title was
|
||||
"Write back: upsert_knowledge if anything changed", which contains neither
|
||||
required tool name).
|
||||
- **Seq-order completion enforcement** (5.6) — no out-of-order completions
|
||||
observed.
|
||||
- **Replaced-status mechanism** (3.3) — pending steps from the prior
|
||||
generation were correctly marked `replaced` on re-propose.
|
||||
|
||||
## What didn't (the findings below)
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. Empty response still ends the session — operator had to start over
|
||||
|
||||
**Where:** Session `722d8878` msg 2: `[System: auto-resume failed after
|
||||
retrying: Nomos returned an empty or unusable response — please retry. The
|
||||
task is paused — send another message to continue.]`
|
||||
|
||||
**What happened:** Turn 1 ran 41 tool calls (set_goal + list_lxcs +
|
||||
get_health_summary + get_state_snapshot + search_knowledge + 4× get_relations
|
||||
+ 4× get_entity + 20× `run` for `apt-get update` across the fleet). The model
|
||||
returned that successfully. Auto-continuation then ran `resumeSession`, which
|
||||
retried `chatWith` **3 times** (continue.go:229) — all three came back empty.
|
||||
The session ended with the system note above. The operator abandoned it and
|
||||
opened `d9cdcee1` with the same prompt.
|
||||
|
||||
**Root cause:** Three identical retries with the same injected `note` produce
|
||||
three identical empty responses (the model isn't randomly failing — it's
|
||||
responding to the prompt the same way each time). The retry loop never varies
|
||||
the prompt, never backs off, and never escalates to a more aggressive
|
||||
recovery (e.g. a fresh continuation prompt that summarizes what just happened
|
||||
and asks explicitly for the next single step).
|
||||
|
||||
**Severity:** Blocker — a 41-tool-call turn costs real money and time, and the
|
||||
operator gets nothing for it.
|
||||
|
||||
### 2. `generation` column exists but `proposePlan` never sets it — frontend grouping is dead code
|
||||
|
||||
**Where:** `cmd/nomos/store.go:458-461` (INSERT statement) vs.
|
||||
`migrations/020_session_reliability.up.sql:7` (the column) and
|
||||
`web/src/lib/components/PlanProgress.svelte:17-22` (the grouping logic).
|
||||
|
||||
**What happened:** Migration 020 added `generation INTEGER NOT NULL DEFAULT 1`
|
||||
and PlanProgress groups steps by `s.generation ?? 1`. But the INSERT in
|
||||
`proposePlan` is:
|
||||
|
||||
```sql
|
||||
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id
|
||||
```
|
||||
|
||||
No `generation` column. Every step, in every plan revision, lands with
|
||||
`generation = 1`. PlanProgress always sees one group ("Current plan") and
|
||||
the collapse-old-generations behavior never triggers.
|
||||
|
||||
**Concrete impact in `d9cdcee1`:**
|
||||
- Turn 1 (msg 1): `propose_plan` creates steps seq 1-5 (all generation 1).
|
||||
- User: "why is the plan not updated accordingly? the steps in the sidebar."
|
||||
- Turn 3 (msg 5): `update_plan_step seq=1, status=done`. Steps 2-5 still
|
||||
pending, all generation 1.
|
||||
- User: "proceed with the rest."
|
||||
- Turn 4 (msg 7): **empty assistant response** (text="", no tools).
|
||||
- Turn 5 (msg 8): Agent calls `propose_plan` **again** with the same 5 steps.
|
||||
`proposePlan` sees `anyStarted=true` (seq 1 is done), so it goes into
|
||||
append mode: marks the 4 still-pending steps (2-5) as `replaced`, then
|
||||
inserts 5 new steps at seq 6-10. **All inserted with generation=1.**
|
||||
- The frontend now sees 10 steps, all `generation: 1`, grouped together.
|
||||
Four are marked `replaced` (visible as "skipped/replaced" — dimmed but
|
||||
still in the list); six are the new active steps.
|
||||
- User: "btw the plan here and the one in the sidebar differ." → Confirmed:
|
||||
the chat text describes a 5-step plan ("Step 1 done, refreshing 2-4");
|
||||
the sidebar shows 10 steps with a confusing mix of done/replaced/running.
|
||||
|
||||
**Severity:** Blocker — this is the direct, observable cause of the user's
|
||||
two complaints in `d9cdcee1`. The prior plan (3.4) shipped the column and
|
||||
the frontend code but never wired the backend INSERT.
|
||||
|
||||
### 3. Agent re-proposes the plan on "proceed" instead of continuing
|
||||
|
||||
**Where:** `d9cdcee1` msg 8 — `propose_plan` called again after user said
|
||||
"proceed with the rest".
|
||||
|
||||
**What happened:** The agent had a perfectly good plan in flight (step 1 done,
|
||||
2-5 pending). On the next operator turn ("proceed"), it should have called
|
||||
`update_plan_step(seq=2, status=running)` and `run` against the targets.
|
||||
Instead it called `propose_plan` with the same 5 steps, triggering the
|
||||
append-mode behavior in #2.
|
||||
|
||||
**Root cause:** SOUL.md doesn't explicitly say "do NOT call propose_plan
|
||||
again once you've already proposed — call update_plan_step + run instead."
|
||||
The agent treated "proceed" as a cue to re-state the plan, not to advance
|
||||
it.
|
||||
|
||||
**Severity:** Friction (compounds #2 into a blocker).
|
||||
|
||||
### 3a. WHY the agent re-proposed instead of advancing — the three-bug chain
|
||||
|
||||
Finding #3's surface description ("agent re-proposed on proceed") is real but
|
||||
doesn't explain the *mechanism*. Tracing the message timestamps and the
|
||||
`auto: true` flag on msg 8 reveals that the re-proposal wasn't the agent's
|
||||
direct response to "proceed with the rest" at all — it was the agent's
|
||||
response to a **generic system reconnect note**, fired by a chain of three
|
||||
compounding bugs:
|
||||
|
||||
**The chain (all confirmed from code + session data):**
|
||||
|
||||
| Step | What happened | Where |
|
||||
|---|---|---|
|
||||
| 1. **Trigger** — model returned empty on the approval | User sent "procceed with the rest." `handleChat` → `a.chat()` → `chatWith()`. The model returned an empty completion 3× (all `maxLLMRetries=2` attempts exhausted). SOUL.md's approval vocabulary was "approved/yes/go ahead" — "proceed" wasn't listed, so the model likely wasn't certain it was approved and no-op'd. | `agent.go:362-371` |
|
||||
| 2. **Amplifier** — empty response misclassified as network disconnect | On empty response, `chatWith` emits `error` and `return`s **without emitting `done`** (agent.go:370-371 — the `done` event only fires on the success path at line 382). The frontend's `onComplete` callback sees `!receivedDone` and treats it as a severed connection, calling `handleDisconnect()`. A *model* empty-response gets handled by the *network* disconnect path. | `agent.go:370-371` (missing `done`) + `chat.ts:349-356` (`!receivedDone → handleDisconnect`) |
|
||||
| 3. **Divergence** — generic reconnect note triggers re-proposal | `handleDisconnect` waits 1s, then sends an empty message (`streamChat('', sessionId, …)`). The backend's reconnect path (main.go:177-188) calls `resumeSession` with: `"[System: the operator's connection was re-established. The task may have progressed in the background — report your current state and progress.]"`. The agent re-read the transcript (plan proposed, step 1 done, user said "proceed"), saw this generic note, and interpreted "report your current state and progress" as "redo the work and report it" → re-proposed + re-executed + `complete_task`. | `chat.ts:386-419` (reconnect) + `main.go:180` (note) + `continue.go:189` (resumeSession) |
|
||||
|
||||
**Timestamps confirm this:** msg 7 (empty) at `11:49:01.949`, msg 8 (re-propose, `auto: true`) at `11:49:16.200` — 15 seconds later, matching the 1s reconnect delay + the LLM call latency. The user never sent a second message; the frontend's reconnect logic did.
|
||||
|
||||
**The user's actual approval ("procceed with the rest") was in the transcript** but the agent wasn't responding to it — it was responding to the *system reconnect note*, which didn't mention approval, the plan, or the user's words. The propose_plan result had said "STOP and wait for approval," and the generic reconnect note didn't say "you're approved" — so the agent re-proposed to get a fresh approval cycle.
|
||||
|
||||
**Why this matters for the fix:** Phase A.2 (refuse re-proposal when in flight) would have *prevented the duplication* but not *fixed the cause*. The agent would have hit the refusal and then… what? With the generic reconnect note, it still doesn't know it's approved. The three bugs need three targeted fixes (Phase B below). This is the answer to "why didn't the agent update the original plan": **it never received a clear signal to advance, because the approval signal was lost in an empty response that got misclassified as a network drop.**
|
||||
|
||||
**Severity:** Blocker — this is the root cause of the plan divergence the
|
||||
operator observed.
|
||||
|
||||
### 4. Operator clarification was interpreted as "redo the whole task"
|
||||
|
||||
**Where:** `d9cdcee1` msg 9 → msg 10. User said "btw the plan here and the
|
||||
one in the sidebar differ." Agent's response (msg 10): re-ran all 6 `run`
|
||||
calls (`apt-get update` + `apt list --upgradable` on nextcloud, jellyfin,
|
||||
hubris), re-called `upsert_knowledge`, and **called `complete_task` a
|
||||
second time**.
|
||||
|
||||
**What happened:** The operator wanted the sidebar aligned with the chat.
|
||||
The agent re-executed the actual audit work and re-completed the task.
|
||||
|
||||
**Root cause:** No prompt-level instruction about how to handle "the UI
|
||||
seems inconsistent" complaints — the agent defaulted to "do the work again,
|
||||
maybe it'll line up this time."
|
||||
|
||||
**Severity:** Friction — wasted 6 `run` calls and a duplicate knowledge
|
||||
entry; user gets a noisier transcript.
|
||||
|
||||
### 5. `complete_task` called twice on the same session
|
||||
|
||||
**Where:** `d9cdcee1` msg 8 and msg 10 both call `complete_task` with
|
||||
`outcome=success`.
|
||||
|
||||
**What happened:** After msg 8, `agent_sessions.status` is `done`. The user
|
||||
complained about the plan drift; the agent re-ran the audit and called
|
||||
`complete_task` again. There's no guard in `completeTask` against re-completing
|
||||
an already-terminal session.
|
||||
|
||||
**Severity:** Cosmetic, but it produces duplicate knowledge entries and
|
||||
erodes audit-log clarity.
|
||||
|
||||
### 6. Turn 1 of `722d8878`: 41 tool calls including `run` against every LXC
|
||||
|
||||
**Where:** Session `722d8878` msg 1.
|
||||
|
||||
**What happened:** Despite a same-day knowledge entry
|
||||
(`investigation:nomos/fleet-wide-apt-update-audit-2026-07-14` — the agent even
|
||||
called `get_knowledge_content` for it), the agent ran `apt-get update` on
|
||||
every LXC in turn 1 instead of presenting the prior audit and proposing a
|
||||
small refresh plan. The agent already had the answer in the DB; it re-ran
|
||||
the fleet audit anyway.
|
||||
|
||||
**Severity:** Friction — wasted ~20 `run` calls (each is a queued execution).
|
||||
The successful retry session (`d9cdcee1`) only re-ran 3 (the critical trio),
|
||||
which is the right pattern — but it had to learn that from the failure
|
||||
session's example.
|
||||
|
||||
### 7. Agent ignores its own writeback warnings
|
||||
|
||||
**Where:** `d9cdcee1` — `propose_plan` returned the nudge from tasks.go:213
|
||||
("⚠️ The final step doesn't mention update_entity_attributes…") and
|
||||
`complete_task` returned the warning from tasks.go:280 ("⚠️ No entity
|
||||
attributes or relationships were updated in this session…"). The agent saw
|
||||
both, did nothing about either, and ended the task.
|
||||
|
||||
**What happened:** The warnings are surfaced in the tool result text, but
|
||||
the model treats tool results as ephemeral context — it doesn't act on a
|
||||
warning that appears after the work it already decided is done. The session
|
||||
recorded zero `update_entity_attributes` calls and zero
|
||||
`create_relationship` calls.
|
||||
|
||||
**Severity:** Blocker — the knowledge-loop drift problem the prior plan was
|
||||
supposed to fix is still happening. The graph accumulates nothing structured
|
||||
from this session; the next fleet audit will rediscover every fact from
|
||||
scratch.
|
||||
|
||||
### 8. Empty assistant bubble persisted in the transcript
|
||||
|
||||
**Where:** `d9cdcee1` msg 7: `{"role":"assistant","text":"","tool_calls":[]}`.
|
||||
|
||||
**What happened:** On the "proceed with the rest" turn, the model returned an
|
||||
empty completion. The inner `chatWith` retry (agent.go:331) eventually
|
||||
succeeded and produced msg 8 — but the empty msg 7 was already persisted to
|
||||
the transcript and stays there. The UI shows an empty assistant bubble between
|
||||
the user's "proceed" and the agent's actual response.
|
||||
|
||||
**Severity:** Cosmetic, but visible to the operator and erodes trust ("is
|
||||
the agent broken?").
|
||||
|
||||
---
|
||||
|
||||
## Improvement plan
|
||||
|
||||
### Phase A — Make `propose_plan` refuse duplication (addresses #2, #3)
|
||||
|
||||
The operator's "plan was added twice" complaint is the visible output of
|
||||
the append-mode safety net added in `5384499`. The safety net was the wrong
|
||||
default: it preserved history but produced a confusing 10-step sidebar. The
|
||||
right default is to **refuse** a re-proposal when a plan is already in
|
||||
flight — the agent must use `update_plan_step` + `run` to advance.
|
||||
|
||||
#### A.1 — `proposePlan`: set `generation` on insert (still needed for history)
|
||||
|
||||
**File:** `cmd/nomos/store.go:415-491`
|
||||
|
||||
**How:**
|
||||
1. Resolve the next generation number at the top of `proposePlan`, in the
|
||||
same transaction:
|
||||
```go
|
||||
var nextGen int
|
||||
if !anyStarted {
|
||||
// fresh/revise: reset to 1 (and the DELETE already wiped old rows)
|
||||
nextGen = 1
|
||||
} else {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(generation), 0) + 1
|
||||
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&nextGen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Add `generation` to the INSERT:
|
||||
```sql
|
||||
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug, generation)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id
|
||||
```
|
||||
Pass `nextGen` as `$6`.
|
||||
3. Include `"generation": nextGen` in the `out` map so the tool result and
|
||||
the `plan.proposed` event carry it (the frontend already reads it via
|
||||
`api.ts:66`).
|
||||
4. Backfill is unnecessary — existing rows default to generation 1.
|
||||
|
||||
#### A.2 — `proposePlan`: refuse re-proposal once any step has started
|
||||
|
||||
**File:** `cmd/nomos/store.go:415-491` + `cmd/nomos/tasks.go:206-219`
|
||||
|
||||
**How:**
|
||||
1. In `proposePlan`, when `anyStarted == true`, return a sentinel error
|
||||
instead of appending:
|
||||
```go
|
||||
if anyStarted {
|
||||
return nil, errPlanInFlight
|
||||
}
|
||||
```
|
||||
2. In `handleTaskTool`'s `propose_plan` case, detect the sentinel and return
|
||||
a directive tool result:
|
||||
```
|
||||
Plan already in flight — refusing duplicate proposal. Steps 1..N exist;
|
||||
at least one is running or done. To advance the plan, call
|
||||
update_plan_step(seq=K, status=running) followed by run(...) for step K's
|
||||
target. Do NOT call propose_plan again. Call it again only if the
|
||||
operator explicitly asks you to revise the whole plan, and if so, say
|
||||
that in your reply before calling it.
|
||||
```
|
||||
3. Drop the append-mode code path (store.go:437-448) — it's the duplication
|
||||
source. Keep the destructive-replace path (store.go:432-436) for the
|
||||
`!anyStarted` case (genuine pre-execution revision).
|
||||
4. The `replaced` status becomes unreachable through normal flow but stays
|
||||
in the schema for any future "explicit revise" path that uses it.
|
||||
|
||||
This is the single highest-impact fix in this plan. It directly removes
|
||||
the "plan added twice" behavior the operator reported, and forces the
|
||||
agent to use the correct advancement tools. Combined with the directive
|
||||
tool result, even a model that ignores SOUL.md will get the right behavior
|
||||
because the bad action is refused.
|
||||
|
||||
#### A.3 — `propose_plan` tool description: state the contract crisply
|
||||
|
||||
**File:** `internal/mcp/server.go` (the `propose_plan` tool schema)
|
||||
|
||||
**How:** Replace the current description with a one-paragraph contract:
|
||||
```
|
||||
Propose the full ordered plan for this task. Call ONCE per task, before
|
||||
any execution. After this call: STOP and wait for operator approval.
|
||||
Once a step has started (status=running/done/...), this tool REFUSES
|
||||
further calls — use update_plan_step + run to advance. The LAST step
|
||||
MUST be "Write back: update_entity_attributes + create_relationship
|
||||
+ upsert_knowledge".
|
||||
```
|
||||
This puts the contract where the model reads it (in the tool schema that
|
||||
gets serialized into the system prompt), not just in SOUL.md where it
|
||||
competes with three overlapping sections.
|
||||
|
||||
#### A.4 — PlanProgress: verify grouping renders with the wired-up column
|
||||
|
||||
**File:** `web/src/lib/components/PlanProgress.svelte:17-90`
|
||||
|
||||
Once A.1 lands, the grouping code that already exists should work. Verify:
|
||||
- Latest generation (`Math.max(...generations)`) → expanded, labeled
|
||||
"Current plan".
|
||||
- Older generations → collapsed by default, labeled "Plan v1 (replaced)",
|
||||
with a count badge.
|
||||
- A future explicit-revise path (not in this plan) would land generation 2
|
||||
as the new "Current plan" and the old steps collapse.
|
||||
|
||||
This is verification, not new code — the structure is there, it just
|
||||
never received varied generation numbers to group on.
|
||||
|
||||
### Phase B — Close the three-bug chain that caused the divergence (addresses #3a, #1, #8)
|
||||
|
||||
Phase A.2 (refuse re-proposal) prevents the *symptom* (duplicate plan in
|
||||
sidebar). This phase fixes the *cause* — the three bugs in finding #3a that
|
||||
made the agent re-propose in the first place. Each fix targets one link in
|
||||
the chain.
|
||||
|
||||
#### B.1 — Emit `done` after `error` so the frontend doesn't misclassify (fixes bug 2 — the amplifier)
|
||||
|
||||
**File:** `cmd/nomos/agent.go:370-371` (+ the other early-return error paths
|
||||
at lines 348, 356)
|
||||
|
||||
**What:** On empty response, `chatWith` emits `error` and returns **without
|
||||
emitting `done`**. The `done` event only fires on the success path
|
||||
(agent.go:382). The frontend's `onComplete` (chat.ts:349-356) sees
|
||||
`!receivedDone` and routes into `handleDisconnect` — treating a *model*
|
||||
failure as a *network* drop, which triggers an unwanted auto-reconnect →
|
||||
`resumeSession` → re-proposal.
|
||||
|
||||
**How:**
|
||||
1. After the `error` emit at line 370, also emit `done` before returning:
|
||||
```go
|
||||
emit(agentEvent{Type: "error", Data: "Nomos returned an empty or unusable response — please retry.", SessionID: sessionID})
|
||||
emit(agentEvent{Type: "done", Data: map[string]any{
|
||||
"session_id": sessionID, "correlation_id": correlationID,
|
||||
"iterations": i + 1, "error": true,
|
||||
}, SessionID: sessionID})
|
||||
return
|
||||
```
|
||||
2. Do the same for the other early-return error paths (agent.go:348 stream
|
||||
error, agent.go:356 no choices) so every terminal path emits `done`.
|
||||
3. On the frontend, `onComplete` (chat.ts:349-356) now sees
|
||||
`receivedDone === true` and sets `streaming.set(false)` instead of
|
||||
calling `handleDisconnect`. The error is still shown via the `error`
|
||||
event handler (chat.ts:329-331).
|
||||
4. Add `"error": true` to the done payload so the frontend can distinguish
|
||||
"ended cleanly" from "ended with error" (e.g. to show a retry button
|
||||
instead of loading dots).
|
||||
|
||||
**Impact:** This alone prevents the unwanted `resumeSession` call after a
|
||||
model empty-response. The error becomes a visible chat error (with the
|
||||
retry button from prior Phase 1.6), not a silent trigger for re-execution.
|
||||
This is the single highest-leverage fix in this phase — it breaks the chain
|
||||
at the amplifier.
|
||||
|
||||
#### B.2 — Reconnect note: reference the user's last message and plan state (fixes bug 3 — the divergence)
|
||||
|
||||
**File:** `cmd/nomos/main.go:180` (reconnect note) + the other resume entry
|
||||
points at `main.go:341` (`/resume` endpoint) and `continue.go:83-86`
|
||||
(idle-sweep note)
|
||||
|
||||
**What:** Even with B.1, genuine network disconnects will still happen. When
|
||||
they do, the reconnect note (`"report your current state and progress"`) is
|
||||
too generic — it doesn't tell the agent what the operator actually wanted,
|
||||
so the agent guesses (badly). The note should carry the operator's last
|
||||
message and whether a plan is in flight.
|
||||
|
||||
**How:**
|
||||
1. Add two helpers to `store.go`:
|
||||
```go
|
||||
func (s *store) lastUserMessage(ctx, sessionID) string // SELECT text FROM messages WHERE session_id=$1 AND role='user' ORDER BY created_at DESC LIMIT 1
|
||||
func (s *store) hasPlanInFlight(ctx, sessionID) bool // SELECT EXISTS(... WHERE session_id=$1 AND status IN ('pending','running'))
|
||||
```
|
||||
2. In `handleChat`'s reconnect path (main.go:177-188), build a specific note:
|
||||
```go
|
||||
lastUserMsg := st.lastUserMessage(pctx, req.SessionID)
|
||||
planInFlight := st.hasPlanInFlight(pctx, req.SessionID)
|
||||
note := fmt.Sprintf("[System: the operator's connection was re-established. "+
|
||||
"The operator's last message was: \"%s\". ", lastUserMsg)
|
||||
if planInFlight {
|
||||
note += "A plan is in flight — advance it with update_plan_step + run. Do NOT call propose_plan again."
|
||||
} else {
|
||||
note += "Report your current state and progress."
|
||||
}
|
||||
note += "]"
|
||||
```
|
||||
3. Apply the same enrichment to the `/resume` endpoint note (main.go:341)
|
||||
and the idle-sweep note (continue.go:83-86) — all three resume entry
|
||||
points should carry the same context.
|
||||
|
||||
**Impact:** Even if B.1 is bypassed (genuine disconnect mid-plan), the agent
|
||||
gets "advance the plan" instead of "report state." No more re-proposal from
|
||||
reconnect.
|
||||
|
||||
#### B.3 — `resumeSession`: escalate the recovery note across attempts (fixes bug 1 — the trigger)
|
||||
|
||||
**File:** `cmd/nomos/continue.go:229-253`
|
||||
|
||||
**What:** The current loop retries 3 times with the same note. A transient
|
||||
model issue (or a prompt causing the model to no-op) gets three identical
|
||||
empty responses.
|
||||
|
||||
**How:**
|
||||
1. Build a different `note` per attempt:
|
||||
```go
|
||||
notes := []string{
|
||||
note, // attempt 0: the original (now enriched per B.2) note
|
||||
fmt.Sprintf("[System: your previous turn produced no response. %s. "+
|
||||
"Produce a response now — call the next tool or report progress in one sentence.]", note),
|
||||
fmt.Sprintf("[System: two consecutive empty responses. Stop trying to be clever. "+
|
||||
"The next action is: pick the lowest-pending plan step, mark it running with "+
|
||||
"update_plan_step, and call run for its target. Do that now.]"),
|
||||
}
|
||||
```
|
||||
2. Pass `notes[attempt]` to `chatWith` so each retry gets a progressively
|
||||
more directive prompt.
|
||||
3. Keep the 3-attempt cap.
|
||||
|
||||
**Impact:** A model that's transiently flaking or confused gets a real
|
||||
second chance with an increasingly specific directive, instead of three
|
||||
identical prompts.
|
||||
|
||||
#### B.4 — Surface the real model error text (addresses finding #1's observability)
|
||||
|
||||
**File:** `cmd/nomos/agent.go:370` + `cmd/nomos/continue.go:255-274`
|
||||
|
||||
**What:** The operator-facing message is "Nomos returned an empty or
|
||||
unusable response — please retry." The actual error (OpenRouter 503,
|
||||
content filter, token limit) is logged but not shown.
|
||||
|
||||
**How:**
|
||||
1. In `chatWith`'s error emit (agent.go:370), include `errText`:
|
||||
```go
|
||||
emit(agentEvent{Type: "error", Data: fmt.Sprintf("Nomos returned an empty or unusable response: %s", errText), SessionID: sessionID})
|
||||
```
|
||||
2. In `resumeSession`'s failure path (continue.go:262):
|
||||
```go
|
||||
resumeFailedNote := fmt.Sprintf(
|
||||
"[System: auto-resume failed after 3 attempts. Last error: %s. "+
|
||||
"The task is paused — send another message to continue.]", errText)
|
||||
```
|
||||
3. The operator can now tell "model overloaded, just retry" from "content
|
||||
filter — I need to rephrase."
|
||||
|
||||
#### B.5 — Back off between resume retries
|
||||
|
||||
**File:** `cmd/nomos/continue.go:229`
|
||||
|
||||
**How:** Add a small sleep before attempts 1 and 2:
|
||||
```go
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-cctx.Done(): return
|
||||
case <-time.After(time.Duration(2<<attempt) * time.Second): // 4s, 8s
|
||||
}
|
||||
}
|
||||
// ... existing body, using notes[attempt] from B.3
|
||||
}
|
||||
```
|
||||
|
||||
#### B.6 — Don't persist the empty placeholder as a visible bubble
|
||||
|
||||
**File:** `cmd/nomos/main.go:251-266` (handleChat placeholder) +
|
||||
`cmd/nomos/continue.go:190-218` (resumeSession placeholder)
|
||||
|
||||
**What:** On `d9cdcee1` msg 7, the empty assistant bubble persisted in the
|
||||
transcript because `persist()` ran with `finalText=""` after the error
|
||||
return. The UI shows an empty bubble.
|
||||
|
||||
**How:**
|
||||
1. Mark the placeholder as pending:
|
||||
`{"role":"assistant","text":"","pending":true}` instead of just `""`.
|
||||
2. The frontend renders `pending: true` as loading dots (it already does
|
||||
this for empty text during streaming), not an empty bubble.
|
||||
3. On success, `persist()` overwrites with real content and drops `pending`.
|
||||
4. In `handleChat`'s final persist call (main.go:282), if `finalText == ""`
|
||||
and `len(toolCalls) == 0`, delete the placeholder row instead of
|
||||
persisting an empty bubble:
|
||||
```go
|
||||
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
|
||||
st.deleteMessage(pctx, msgID)
|
||||
} else {
|
||||
persist()
|
||||
}
|
||||
```
|
||||
|
||||
### Phase C — Stop the agent re-executing on clarification (addresses #4, #5)
|
||||
|
||||
#### C.1 — `completeTask`: reject re-completion of a terminal session
|
||||
|
||||
**File:** `cmd/nomos/store.go:completeTask`
|
||||
|
||||
**How:**
|
||||
1. Before the UPDATE, fetch the current status. If it's already `done`,
|
||||
`failed`, or `partial`, return without re-updating and surface a no-op
|
||||
message:
|
||||
```go
|
||||
var current string
|
||||
s.pool.QueryRow(ctx, `SELECT status FROM agent_sessions WHERE id=$1`, sessionID).Scan(¤t)
|
||||
if current == "done" || current == "failed" || current == "partial" {
|
||||
return nil // already terminal — silently no-op
|
||||
}
|
||||
```
|
||||
Or, stronger, return an error from `completeTask` and have the caller
|
||||
(tasks.go:275) translate it into a tool-result message:
|
||||
`"Session is already complete (status=done). If you want to keep working, call update_plan_step + run; do not call complete_task again."`
|
||||
|
||||
2. The error path is preferred — the agent sees it in the tool result and
|
||||
stops trying to re-complete.
|
||||
|
||||
#### C.2 — SOUL.md: handle "the UI is inconsistent" complaints without re-executing
|
||||
|
||||
**File:** `nomos/SOUL.md`
|
||||
|
||||
**How:** Add a short rule:
|
||||
```
|
||||
If the operator points out that the chat and the sidebar/plan panel disagree,
|
||||
DO NOT re-run the work. Investigate the discrepancy by reading state:
|
||||
get_plan_steps / list current step states → reconcile with a single
|
||||
update_plan_step call. If the panel is correct and the chat is stale,
|
||||
summarize the panel in your reply. If the chat is correct and the panel
|
||||
is stale, fix the panel with update_plan_step. Never re-execute tool
|
||||
work just to fix a display mismatch.
|
||||
```
|
||||
|
||||
### Phase D — Writeback enforcement that actually sticks (addresses #7)
|
||||
|
||||
The current warnings are too easy to ignore because they appear after the
|
||||
agent has already moved on mentally. Make them structural.
|
||||
|
||||
#### D.1 — `completeTask`: refuse to mark success without writeback when state was discovered
|
||||
|
||||
**File:** `cmd/nomos/store.go:completeTask` + `cmd/nomos/tasks.go:254-282`
|
||||
|
||||
**How:** Convert the warning into a refusal when the session actually ran
|
||||
discovery tools:
|
||||
1. Extend `hadEntityWriteback` (store.go:624) into `hadDiscoveryAndWriteback`:
|
||||
```sql
|
||||
-- did the session run discovery?
|
||||
SELECT EXISTS(SELECT 1 FROM audit_log
|
||||
WHERE session_id=$1 AND tool_name IN ('run','get_entity','get_relations','list_lxcs','list_entities'))
|
||||
-- AND did it write back?
|
||||
SELECT EXISTS(SELECT 1 FROM audit_log
|
||||
WHERE session_id=$1 AND tool_name IN ('update_entity_attributes','create_relationship'))
|
||||
```
|
||||
2. In `completeTask`, if `discovery=true AND writeback=false` AND `outcome`
|
||||
is `success`:
|
||||
- **Force-downgrade** the outcome to `partial`.
|
||||
- Return a hard error (not just a warning) that the agent must act on:
|
||||
`"Refused: this session ran discovery (run/get_entity/...) but did not call update_entity_attributes or create_relationship. Call those now to persist the facts you learned, then call complete_task again. Outcome downgraded to 'partial' until you do."`
|
||||
3. The agent gets the error in the tool result, sees the directive, and is
|
||||
forced to call `update_entity_attributes` before it can complete.
|
||||
|
||||
This is the structural version of 5.4/5.5 from the prior plan — warnings
|
||||
didn't work; enforcement will.
|
||||
|
||||
#### D.2 — `propose_plan`: auto-append a writeback step if missing
|
||||
|
||||
**File:** `cmd/nomos/tasks.go:206-219`
|
||||
|
||||
**How:** Instead of (or in addition to) the warning string, append a
|
||||
synthetic writeback step when none of the proposed steps mention
|
||||
`update_entity_attributes`:
|
||||
```go
|
||||
hasWritebackStep := false
|
||||
for _, s := range steps {
|
||||
if strings.Contains(s.Title+s.Detail, "update_entity_attributes") ||
|
||||
strings.Contains(s.Title+s.Detail, "create_relationship") {
|
||||
hasWritebackStep = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWritebackStep {
|
||||
steps = append(steps, planStepInput{
|
||||
Title: "Write back entity attributes and relationships",
|
||||
Detail: "Call update_entity_attributes for every entity you ran run/get_entity against (versions, states, hosts, IPs), and create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities.",
|
||||
})
|
||||
// re-call proposePlan with the extended steps, or append directly to the
|
||||
// already-persisted plan via a second INSERT.
|
||||
}
|
||||
```
|
||||
The agent then sees the explicit step in its own plan and the seq-order
|
||||
enforcement (5.6) forces it to complete that step last.
|
||||
|
||||
### Phase E — Reduce turn-1 fan-out (addresses #6)
|
||||
|
||||
The `722d8878` failure session spent 41 tool calls re-discovering what was
|
||||
already in the DB.
|
||||
|
||||
#### E.1 — SOUL.md: prefer knowledge over re-execution
|
||||
|
||||
**File:** `nomos/SOUL.md`
|
||||
|
||||
**How:** Add to the discovery section:
|
||||
```
|
||||
BEFORE calling `run` for fleet-wide facts (apt counts, service versions,
|
||||
host states), call search_knowledge and get_knowledge_content for the
|
||||
relevant entity or topic. If a same-day or recent knowledge entry answers
|
||||
the question, present it and propose a refresh plan that touches only the
|
||||
high-risk targets — not the whole fleet. Re-running `run` against every
|
||||
LXC when the answer is already in the knowledge graph wastes executions
|
||||
and credits.
|
||||
```
|
||||
|
||||
#### E.2 — `list_lxcs`: include last-audited hint in the result
|
||||
|
||||
**File:** `internal/mcp/server.go:list_lxcs` handler
|
||||
|
||||
**How:** When returning LXCs, include for each row the most recent
|
||||
`knowledge_entities.created_at` linked via `about` edges with kind
|
||||
`investigation` or `document` and a tag matching `audit`/`update`. The
|
||||
agent then sees "nextcloud — last audited 2026-07-14 (today)" and can skip
|
||||
re-running it.
|
||||
|
||||
This is a smaller tweak than E.1 (which is the load-bearing fix) — the data
|
||||
hint makes the SOUL.md rule easy to follow.
|
||||
|
||||
---
|
||||
|
||||
### Phase F — SOUL.md: be crisp, not repetitive (addresses the operator's "more crisp and clear with the agent" feedback)
|
||||
|
||||
SOUL.md grew three overlapping sections across the last 20 commits:
|
||||
|
||||
| Section | Added by | Says |
|
||||
|---|---|---|
|
||||
| `## ⚠️ MANDATORY TASK FLOW` (top) | `5caf49b` (Jul 14) | 6-step flow: set_goal → pre-plan → propose → approve → execute → writeback |
|
||||
| `## Every chat is a task` (mid) | `e30813a` (Jul 11) | Same 6-step flow, longer, plus the trivial-task degenerate case |
|
||||
| `### ⚠️ AFTER EVERY TASK: WRITE BACK OR LOSE IT` (inside "Every chat") | `60effcb` (Jul 14) | Writeback rule, third time |
|
||||
|
||||
The agent has three places telling it the same thing. The MANDATORY TASK
|
||||
FLOW section at the top is the right one to keep — it's the most directive
|
||||
and the closest to the system-prompt boundary. The other two are
|
||||
lower-fold repetition that bloats context and dilutes the directive.
|
||||
|
||||
#### F.1 — Consolidate SOUL.md to one task-flow section
|
||||
|
||||
**File:** `nomos/SOUL.md`
|
||||
|
||||
**How:**
|
||||
1. Keep the `## ⚠️ MANDATORY TASK FLOW` section at the top verbatim — it's
|
||||
the load-bearing version.
|
||||
2. Replace the `## Every chat is a task` section (lines ~85-165) with a
|
||||
three-line reference: "Every non-trivial chat follows the MANDATORY
|
||||
TASK FLOW at the top of this file. The flow scales down: a trivial
|
||||
read-only question (e.g. 'status of Y?') is a degenerate case — answer
|
||||
directly and call `complete_task` with a one-line summary, no
|
||||
propose_plan ceremony."
|
||||
3. Remove the `### ⚠️ AFTER EVERY TASK: WRITE BACK OR LOSE IT` subsection
|
||||
entirely — its content is already step 6 of MANDATORY TASK FLOW and
|
||||
step 3 of "Every chat is a task." Three statements of the same rule
|
||||
don't make it more enforced; they make the file longer.
|
||||
4. Result: the file is ~80 lines shorter, the agent has one place to read
|
||||
the task contract, and the directive is unmissable because it's no
|
||||
longer competing with two paraphrased copies.
|
||||
|
||||
This is reversible prose work, but it directly addresses the operator's
|
||||
feedback that the agent isn't being "crisp and clear" with itself.
|
||||
|
||||
#### F.2 — Make tool-result strings directive, not advisory
|
||||
|
||||
**Files:** `cmd/nomos/tasks.go` (the result strings for `set_goal`,
|
||||
`propose_plan`, `update_plan_step`, `complete_task`)
|
||||
|
||||
**How:** Audit each tool-result string for hedging language and tighten:
|
||||
|
||||
| Current | Tightened |
|
||||
|---|---|
|
||||
| `"Goal set: <goal>. Now do a PRE-PLAN: gather information with read-only tools ... Do NOT call run yet."` | `"Goal set. NEXT: pre-plan (read-only tools only). Then propose_plan. Do not call run."` |
|
||||
| `"Plan set: N step(s). Now STOP and present the plan to the operator — do NOT call run yet. Wait for them to approve ..."` | `"Plan set (N steps). STOP. Wait for operator approval. Do not call run."` |
|
||||
| `"Step N → status"` | `"Step N → status. (Use update_plan_step to advance; do not re-propose.)"` — only on the first call per session, otherwise unchanged. |
|
||||
| `"⚠️ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch."` | (Replaced by D.1's refusal when discovery ran.) |
|
||||
|
||||
Short, imperative, no hedging. The agent's behavior in `d9cdcee1` shows
|
||||
that long tool-result strings with "consider revising the last step" are
|
||||
treated as informational; short imperatives ("STOP. Do not call run.")
|
||||
are followed.
|
||||
|
||||
#### F.3 — State the approval vocabulary in the plan-result string
|
||||
|
||||
**File:** `cmd/nomos/tasks.go:206-219` (propose_plan result)
|
||||
|
||||
**How:** Add the approval vocabulary to the propose_plan result so the
|
||||
agent recognizes "proceed", "go", "continue", "yes", "approved", "ok" as
|
||||
approval and does NOT re-propose on those:
|
||||
```
|
||||
Plan set (N steps). STOP. Wait for operator approval.
|
||||
Approval vocabulary: "approved", "yes", "go", "proceed", "continue", "ok".
|
||||
On approval, advance with update_plan_step + run. Do NOT call propose_plan again.
|
||||
```
|
||||
This directly addresses finding #3's cause: the agent re-proposed on
|
||||
"proceed with the rest" because SOUL.md only listed "approved / yes / go
|
||||
ahead" as approval vocabulary. Make the list match what operators
|
||||
actually type.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing & priority
|
||||
|
||||
| # | Fix | Effort | Impact | Phase |
|
||||
|---|---|---|---|---|
|
||||
| A.2 | `proposePlan` refuses re-proposal when in flight | S | **Blocker** — directly removes the duplication the operator saw | A |
|
||||
| B.1 | Emit `done` after `error` in `chatWith` | S | **Blocker** — breaks the three-bug chain at the amplifier | B |
|
||||
| B.2 | Reconnect note carries user's last message + plan state | S | **Blocker** — fixes the divergence cause | B |
|
||||
| A.1 | Set `generation` on INSERT | S | High — needed for any future explicit-revise flow | A |
|
||||
| A.3 | `propose_plan` tool description states the contract | S | High — agent reads tool schema, often ignores SOUL.md | A |
|
||||
| F.1 | Consolidate SOUL.md to one task-flow section | S | High — addresses "be more crisp" feedback directly | F |
|
||||
| F.2 | Tighten tool-result strings to imperatives | S | Medium — observable behavior change | F |
|
||||
| F.3 | Approval vocabulary in propose_plan result | S | High — fixes the "proceed" → empty-response trigger | F |
|
||||
| B.3 | Escalate recovery note per resume retry | S | High — turns 3 identical empties into a real recovery | B |
|
||||
| D.1 | Refuse `complete_task` without writeback | M | **Blocker** — fixes the knowledge loop | D |
|
||||
| D.2 | Auto-append writeback step to plans | M | High — addresses the cause | D |
|
||||
| B.4 | Surface real model error text | S | Medium — operator can diagnose | B |
|
||||
| C.1 | Reject re-completion of terminal sessions | S | Medium — stops duplicate `complete_task` | C |
|
||||
| C.2 | SOUL.md: don't re-execute on UI complaints | S | Medium — prevents the 6 wasted `run` calls | C |
|
||||
| B.5 | Back off between resume retries | S | Low-medium | B |
|
||||
| B.6 | Don't persist empty placeholder as bubble | M | Cosmetic — but visible to operators | B |
|
||||
| A.4 | Verify PlanProgress grouping renders | S | Depends on A.1 | A |
|
||||
| E.1 | SOUL.md: prefer knowledge over re-execution | S | Medium — saves credits on fleet audits | E |
|
||||
| E.2 | `list_lxcs` last-audited hint | M | Low — nice-to-have | E |
|
||||
|
||||
**Suggested order:** A.2 + B.1 + B.2 (the three blockers, ship together) →
|
||||
F (crispness, ships alongside) → A.1/A.3/A.4 → D → B.3/B.4/B.5/B.6 → C → E.
|
||||
|
||||
The three blockers form a complete fix for the operator's reported bug:
|
||||
- **A.2** stops the duplication from being *possible* (refuse re-proposal).
|
||||
- **B.1** stops the empty response from *triggering* a reconnect/resume
|
||||
(emit `done` after `error`).
|
||||
- **B.2** makes any *genuine* reconnect carry the right context (advance
|
||||
the plan, don't re-report).
|
||||
Together they close the three-bug chain end-to-end. F.3 (approval
|
||||
vocabulary) closes the *trigger* of the empty response itself.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After deploying each phase, replay the same operator prompt in a fresh
|
||||
session and check:
|
||||
|
||||
- **Phase A:** Call `propose_plan` twice (manually if needed) and confirm
|
||||
the sidebar shows "Current plan" + a collapsed "Plan v1 (replaced)"
|
||||
section, not a flat 10-step list.
|
||||
- **Phase B:** Force an empty response (e.g. temporarily throttle OpenRouter
|
||||
to 0 RPM, or use a stub model that returns `""`). Confirm: (a) the
|
||||
frontend shows the error inline and does NOT trigger a reconnect/resume
|
||||
(no `auto: true` message appears 15 seconds later); (b) the operator sees
|
||||
the real error text, not "empty or unusable response"; (c) if you then
|
||||
disconnect the network for real, the reconnect note says "advance the
|
||||
plan" (not "report state") and the agent calls `update_plan_step` + `run`,
|
||||
not `propose_plan`.
|
||||
- **Phase C:** Start a session, let it `complete_task`, then send a follow-up
|
||||
complaint. Confirm the agent does NOT call `complete_task` again and does
|
||||
NOT re-run the original `run` calls.
|
||||
- **Phase D:** Run a fleet-audit prompt. Confirm the agent cannot reach
|
||||
`complete_task` with `outcome=success` without first calling
|
||||
`update_entity_attributes` for at least the LXCs it ran `run` against.
|
||||
- **Phase E:** Confirm a same-day audit prompt produces a turn-1 with ≤5
|
||||
tool calls (search_knowledge + get_knowledge_content + small
|
||||
propose_plan), not 41.
|
||||
- **Phase F:** Count SOUL.md lines (target: ~80 fewer than current). Replay
|
||||
the "proceed with the rest" prompt and confirm the agent does NOT call
|
||||
`propose_plan` again (it gets a refusal error on the call, then advances
|
||||
via `update_plan_step` + `run`).
|
||||
1174
plans/2026-07-14-session-reliability-and-ux-audit.md
Normal file
1174
plans/2026-07-14-session-reliability-and-ux-audit.md
Normal file
File diff suppressed because it is too large
Load Diff
92
plans/2026-07-14-tool-timeline-sidebar.md
Normal file
92
plans/2026-07-14-tool-timeline-sidebar.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# 2026-07-14 — Tool timeline in sidebar + session analysis
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
## Session analysis: `1d614a1f` (2026-07-14T09:21)
|
||||
|
||||
"Audit all homelab hosts and active LXCs for pending apt updates"
|
||||
|
||||
### What went well
|
||||
- `list_lxcs(state="active")` worked — only returned live LXCs (our Phase 5 fix)
|
||||
- Agent discovered that `apt-get update` on host targets gets classified as `config_mutation` (modifies apt cache) — all 9 needed approval, user bulk-approved
|
||||
- 162 tool calls across 4 turns: 30 + 78 + 0 (auto-resume fail) + 54 = legitimate fleet audit
|
||||
- Our Phase 1 fix worked: 3 auto-resume failures persisted "task is paused" notes instead of auto-failing
|
||||
|
||||
### What failed
|
||||
- **Agent never called `complete_task`.** Session status stuck at `"planning"` despite:
|
||||
- `set_goal` called in first turn
|
||||
- 162 tool calls executed
|
||||
- Final message has full audit text
|
||||
- But `propose_plan` never cleared the gate — the agent entered planning and never left
|
||||
- The 3 auto-resume failures filled the transcript with `[System: auto-resume failed…]` noise
|
||||
|
||||
### Root cause: `apt-get update` triggers `config_mutation` classification
|
||||
- The command classifier correctly treats `apt-get update` as state-changing (it writes to the apt cache)
|
||||
- But the agent just wanted to READ package lists. The audit batch of 9 `apt-get update` calls all needed approval
|
||||
- Lesson: `apt-get update` should be in a separate audit/update pair where the audit phase uses a read-only inspection command (e.g. `apt list --upgradable` doesn't mutate cache)
|
||||
|
||||
### Tool usage pattern
|
||||
- 30 tool calls in turn 1 (set_goal, propose_plan, list_lxcs, run × 9 for apt update on hosts, update_plan_step × 8, ask_operator)
|
||||
- 78 tool calls in turn 3 (run × 25+ for apt audits, get_execution_status × 10+, update_plan_step × 10)
|
||||
- 54 tool calls in turn 7 (final result synthesis)
|
||||
|
||||
These 162 tool calls are ALL rendered inline in chat today. The operator sees a massive wall of collapsed ToolCallGroup entries.
|
||||
|
||||
---
|
||||
|
||||
## Plan
|
||||
|
||||
### 1. Scroll fixes (DONE above)
|
||||
- Activity bar moved to bottom of messages (before messagesEnd)
|
||||
- Smart scroll: auto-scroll only during streaming OR when user is near bottom
|
||||
- Scrolling up pauses auto-scroll until user sends a new message
|
||||
|
||||
### 2. Tool timeline in sidebar
|
||||
|
||||
**Goal:** Decouple tool execution noise from conversation. Chat shows agent's
|
||||
thinking; sidebar shows what it's doing.
|
||||
|
||||
#### 2.1 — Chat: compact tool indicator
|
||||
Replace the full ToolCallGroup in chat with a single compact line:
|
||||
```
|
||||
[N tools used — view in Activity]
|
||||
```
|
||||
- Clicking it opens/highlights the sidebar timeline
|
||||
- Pending approvals still show inline in chat (InlineApproval stays)
|
||||
- Inline tool renderers (entity cards, health summary, etc.) stay — they're
|
||||
informational, not noise
|
||||
|
||||
#### 2.2 — Sidebar: live tool timeline
|
||||
The "This session" section (SessionDigest) becomes a live tool timeline:
|
||||
- Each agent turn gets a timestamp header
|
||||
- Within each turn: tool calls shown as a compact list with status icons
|
||||
(running spinner / done check / failed X)
|
||||
- Tool names are the same compact format from ToolCallGroup
|
||||
- Results stay collapsible (click to expand)
|
||||
- Auto-scrolls to latest, but doesn't force-follow if user is reading history
|
||||
- UPDATEs live (no reload needed) — same polling mechanism as SessionDigest
|
||||
|
||||
#### 2.3 — Sidebar: merge plan steps
|
||||
PlanProgress and SessionDigest merge into one "Activity" panel:
|
||||
- Top: plan steps with progress bar (from PlanProgress)
|
||||
- Middle: live tool timeline (from SessionDigest)
|
||||
- Bottom: knowledge created this session (from SessionDigest)
|
||||
|
||||
### 3. `complete_task` enforcement (session finding)
|
||||
|
||||
The agent called `set_goal` and then the task entered `planning` status.
|
||||
But the flow is:
|
||||
- `set_goal` → status changes to `planning`
|
||||
- `propose_plan` → status changes to `executing`
|
||||
|
||||
The agent called `set_goal` but never `propose_plan` that clears `planning`.
|
||||
Looking at the code: `setGoal` in store.go sets `status = 'planning'`, and
|
||||
`proposePlan` sets `status = 'executing'`. So the agent must have called
|
||||
`set_goal` but the subsequent `propose_plan` failed or the agent skipped it.
|
||||
|
||||
**Fix:** In `setGoal`, if the agent has enough information to propose a plan,
|
||||
auto-transition to `executing` when the first tool call is made (not when
|
||||
`set_goal` is called — that's too early). The status `planning` should only
|
||||
stick if the agent explicitly calls `ask_operator` for more info. Otherwise,
|
||||
status `planning` is indistinguishable from `active` — it just means the
|
||||
agent never formalized the transition.
|
||||
118
plans/2026-07-14-unified-agent-indicator.md
Normal file
118
plans/2026-07-14-unified-agent-indicator.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 2026-07-14 — Unified agent activity indicator
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
## Current state — three separate indicators
|
||||
|
||||
| Component | Location | Shows |
|
||||
|---|---|---|
|
||||
| Loading dots (Chat.svelte:146) | Inline in assistant bubble | 3 bouncing dots when no text/tools yet |
|
||||
| ToolCallGroup trigger row | Inline in assistant bubble | "12 tools" with spinner |
|
||||
| Activity bar | Bottom of message list | "Agent is responding…" / "Working" / Continue button |
|
||||
|
||||
All three overlap. The operator sees dots → then a tool count → then the activity bar — three different visual styles for the same thing: "the agent is working."
|
||||
|
||||
## Target: single indicator appended to conversation
|
||||
|
||||
One row, always the last item in the message list, that replaces the loading dots, ToolCallGroup summary, and activity bar. Think of it like a system message appended at the end of the conversation.
|
||||
|
||||
### Behavior
|
||||
|
||||
```
|
||||
User: "audit fleet"
|
||||
Assistant: "I'll check all hosts. Here's the plan..." ← full message bubble
|
||||
|
||||
┌─ Agent is auditing… ───────────────────┐
|
||||
│ ◉ apt update on lxc:jellyfin │ ← spinner + current action
|
||||
└────────────────────────────────────────┘
|
||||
|
||||
... agent finishes ...
|
||||
|
||||
Assistant: "Done. 19 LXCs have pending updates." ← next message bubble
|
||||
```
|
||||
|
||||
The indicator:
|
||||
- **Appears** when the agent starts working (first `tool_use` event or `streaming=true`)
|
||||
- **Updates** its description with the current tool name in flight
|
||||
- **Collapses/disappears** when the turn ends (`done` event or `streaming=false`)
|
||||
- If there were tools, shows a brief completion summary for 3 seconds then fades
|
||||
- During auto-continuation (polling picks up new messages), reappears if the agent did tool calls
|
||||
|
||||
### States
|
||||
|
||||
| State | Icon | Description |
|
||||
|---|---|---|
|
||||
| Thinking | ◉ pulse | "Agent is thinking…" |
|
||||
| Planning | ◉ pulse | "Building plan…" |
|
||||
| Researching | ◉ pulse | "Researching <entity>…" |
|
||||
| Executing | ◉ spinner | "<tool_name> <target>…" |
|
||||
| Done | ✓ | Fades out after 3s |
|
||||
|
||||
### Data source
|
||||
|
||||
The description comes from the most recent `tool_use` event's name + args. If no tools yet, show generic "thinking" message. The derived `toolTimeline` store already has this data.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. New component: `AgentIndicator.svelte`
|
||||
|
||||
**Props:** `active: boolean`, `lastTool: ToolCallResult | null`, `toolCount: number`
|
||||
|
||||
Renders a single compact row:
|
||||
```html
|
||||
<div class="activity-indicator">
|
||||
<LoaderCircle class="animate-spin" /> <!-- or CheckIcon when done -->
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
`label` is derived:
|
||||
```ts
|
||||
const label = $derived.by(() => {
|
||||
if (!active) return ''
|
||||
if (!lastTool) return 'Agent is thinking…'
|
||||
const args = lastTool.args ?? {}
|
||||
switch (lastTool.name) {
|
||||
case 'set_goal': return 'Setting goal…'
|
||||
case 'propose_plan': return 'Building plan…'
|
||||
case 'search_knowledge': return `Researching: ${args.query ?? ''}`
|
||||
case 'get_entity': return `Looking up ${args.slug_or_id ?? ''}`
|
||||
case 'run': return `${args.purpose ?? 'Running command…'}`
|
||||
case 'list_lxcs': return 'Listing containers…'
|
||||
case 'update_plan_step': return 'Updating progress…'
|
||||
case 'upsert_knowledge': return 'Recording knowledge…'
|
||||
case 'complete_task': return 'Wrapping up…'
|
||||
default: return `${lastTool.name}…`
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Chat.svelte changes
|
||||
|
||||
- **Remove** activity bar from bottom of messages
|
||||
- **Replace** the 3 bouncing dots `{#if msg.tools.length === 0}` with nothing (the indicator covers this)
|
||||
- **Add** `<AgentIndicator>` after the `{#each}` loop, before `messagesEnd`
|
||||
- The indicator shows when `$streaming || liveStatus === 'executing'`
|
||||
- Pass `lastTool` from `$toolTimeline` — the last tool_use entry
|
||||
|
||||
### 3. Remove activity bar code
|
||||
|
||||
Delete the `{#if $currentSession && $messages.length > 0}` block at the bottom (already moved once, now deleted entirely — replaced by AgentIndicator).
|
||||
|
||||
### 4. Remove loading dots
|
||||
|
||||
In Chat.svelte, remove the 3 bouncing dots block:
|
||||
```svelte
|
||||
{:else if msg.tools.length === 0}
|
||||
<div class="flex items-center gap-1.5 py-1 text-sm text-muted-foreground">
|
||||
<span class="size-1.5 animate-bounce rounded-full bg-current ...">...</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
- Send "status" → indicator appears "Agent is thinking…" → agent responds → indicator fades
|
||||
- Send "check updates on jellyfin" → indicator shows "Researching…" → "Listing containers…" → "Running apt list…" → fades
|
||||
- Auto-continuation fires → indicator reappears with current tool → fades when done
|
||||
- Scroll up during agent work → indicator stays at bottom of message list (it's just a message)
|
||||
- Error during agent work → indicator shows "Error: …" with X icon
|
||||
@@ -1,8 +1,9 @@
|
||||
# 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.
|
||||
`0c0f35a`, 2026-07-12). Phases 1.0–1.4 implemented and iterated (commits
|
||||
through `eeb78ed`, 2026-07-14). App installed to `/Applications` on dev Mac,
|
||||
end-to-end OIDC login via lokal HTTP server + system browser confirmed working.
|
||||
|
||||
**Production deploy (2026-07-12):** merged to `main`, picked up by the
|
||||
2-minute deploy poller (`scripts/deploy.sh`: pg_dump backup → rebuild →
|
||||
|
||||
369
plans/done/2026-07-15-plan-first-and-iteration.md
Normal file
369
plans/done/2026-07-15-plan-first-and-iteration.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# 2026-07-15 — Plan-first enforcement, iteration, and audit gaps
|
||||
|
||||
**Status:** Done — 2026-07-15. P1–P6 implemented; build + tests + vet
|
||||
pass. Follow-up to the session audit of
|
||||
`d0d562e0` ("Determine when the last backup to Proton Drive ran and when the
|
||||
next one is scheduled"). The audit surfaced that the agent answered
|
||||
successfully but **never proposed a plan**, and that a follow-up asking the
|
||||
agent to act on its own findings has no working path. This plan closes both,
|
||||
plus the related reliability gaps the audit turned up.
|
||||
|
||||
Grounded in:
|
||||
- `cmd/nomos/tasks.go` (set_goal, propose_plan, complete_task handlers)
|
||||
- `cmd/nomos/store.go` (setGoal, proposePlan, completeTask, plan window)
|
||||
- `internal/mcp/server.go` (run risk gate, plan/assent windows)
|
||||
- `internal/policy/command.go` (ClassifyCommand read-only allowlist)
|
||||
- `nomos/SOUL.md` (MANDATORY TASK FLOW + degenerate-case carve-out)
|
||||
- `cmd/nomos/eval/manifest.go` + `eval/main.go` (assertion kinds, followup)
|
||||
|
||||
---
|
||||
|
||||
## Why there was no plan
|
||||
|
||||
Two independent causes, both required for the skip to happen:
|
||||
|
||||
1. **SOUL.md explicitly exempts read-only questions from the plan flow.**
|
||||
`SOUL.md:51-53` and `SOUL.md:111-115` declare a "trivial read-only task
|
||||
('status of Y?')" a *degenerate case*: answer directly, `complete_task`
|
||||
with a one-line summary, "no propose_plan ceremony." The Proton Drive
|
||||
question looks on its face like "status of Y?", so the agent applied the
|
||||
carve-out. It then went on to call `run` twice — so it wasn't actually
|
||||
degenerate, but the exemption had already been invoked.
|
||||
|
||||
2. **There is no structural gate forcing `propose_plan` before `run`.**
|
||||
The only enforcement is SOUL.md prose. `internal/mcp/server.go:1312`
|
||||
executes read-only commands immediately with no check that a plan exists
|
||||
for the session. The agent can honor the rule or skip it, and weaker
|
||||
models skip it. The D.1 writeback gate works precisely because it's
|
||||
*structural* (`complete_task` refuses without `update_entity_attributes`);
|
||||
there is no equivalent for `propose_plan`.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### F1 — BLOCKER. Plan window opens on `set_goal`, before any plan or approval.
|
||||
|
||||
`tasks.go:183` calls `openPlanWindow` inside the `set_goal` handler →
|
||||
`nomos:plan:<session>="active"` immediately (`store.go:473`). Result: any
|
||||
`config_mutation` `run` auto-executes with **zero operator approval**. This
|
||||
session proves it — no `propose_plan`, no `update_plan_step` (confirmed:
|
||||
`/sessions/{id}/plan` → `steps:null`), yet the 2nd `run` was tagged
|
||||
**"config_mutation, auto via plan"** (`server.go:1331-1344`). The inline
|
||||
comment ("the goal IS the start of a plan… operator approves the plan via
|
||||
propose_plan") is self-contradictory: the window is already open, so
|
||||
`propose_plan`'s "STOP, wait for approval" (`tasks.go:248`) is unenforceable.
|
||||
|
||||
This is a safety regression, not a style issue.
|
||||
|
||||
### F2 — BLOCKER. Iterative follow-ups have no working path.
|
||||
|
||||
Scenario: this session completes; the operator sends a follow-up "now look
|
||||
into the `repos` backup failure" on the same session. Traced path:
|
||||
|
||||
1. `completeTask` (`store.go:716-718`) deletes `nomos:plan:<session>` from
|
||||
`autonomy_settings` but does **not** clear `session_plan_steps` rows.
|
||||
2. Follow-up arrives → `main.go:227` `touchSession` only updates
|
||||
`last_active_at`; status stays `done`.
|
||||
3. Agent calls `set_goal` → `setGoal` flips status to `executing`
|
||||
(`store.go:460`) **and re-opens the plan window** (F1 again).
|
||||
4. Agent calls `propose_plan` → `proposePlan` (`store.go:517-527`) checks
|
||||
`bool_or(status <> 'pending')`. Old steps are all `done` →
|
||||
`anyStarted=true` → returns `errPlanInFlight` → **REFUSED**. The refusal
|
||||
text says "Re-propose only if the operator explicitly asks" but there is
|
||||
**no code path honoring that** — re-calling `propose_plan` hits the same
|
||||
guard. Dead end. There is no `reset_plan`/`close_plan` tool.
|
||||
|
||||
So the design assumed one plan per session. There is no "iteration" /
|
||||
"next plan" concept. The only escape is starting a brand-new session, which
|
||||
loses the conversational thread and the LLM's replayed context.
|
||||
|
||||
Note: in *this* audited session there were no plan steps (F1 — no plan was
|
||||
ever proposed), so `proposePlan` would actually succeed on a follow-up here.
|
||||
But in a plan-always world the first session WOULD have steps, and the
|
||||
follow-up would be blocked. **Fixing plan-always without fixing iteration
|
||||
would create a new blocker.** They must ship together.
|
||||
|
||||
### F3 — FRICTION. Thinking replaced by summary on reload.
|
||||
|
||||
`main.go:252-267` and `continue.go:204-219` persist **one** placeholder
|
||||
assistant row per turn and `updateMessage` it per tool call, storing only
|
||||
`finalText` (the *last* `text` event) + an ever-growing `toolCalls` slice.
|
||||
Intermediate per-turn reasoning (streamed live via `text`/`text_delta`,
|
||||
`agent.go:360,410`) is **overwritten**. The DB has 2 rows total for this
|
||||
session; on reload you see only the final 547-char summary + a flat list of
|
||||
15 tool calls. Same defect breaks LLM replay fidelity on resume — the model
|
||||
can't see its own prior reasoning.
|
||||
|
||||
### F4 — FRICTION. Read-only command misclassified as `config_mutation`.
|
||||
|
||||
The 2nd `run` was pure inspection (`ls|head|tail|echo|find|journalctl`) but
|
||||
classified `config_mutation` because **`find` is absent** from
|
||||
`readOnlyLeadPattern` (`command.go:69-78`); `allSegmentsReadOnly` trips on
|
||||
the `find` segment and escalates. Harmless here only because F1 auto-ran it
|
||||
anyway — but in a properly-gated session it would force an unnecessary
|
||||
approval, and it masks the real danger of F1.
|
||||
|
||||
### F5 — COSMETIC. Contradictory `set_goal` instruction.
|
||||
|
||||
`set_goal` returns "Then propose_plan. Do not call run" (`tasks.go:184`) for
|
||||
*every* task, yet a read-only inspection task legitimately needs `run` and
|
||||
doesn't need a plan (under the current carve-out). The guidance is both
|
||||
ignored (F1) and wrong for this task class. Resolved by F6's plan-always
|
||||
model.
|
||||
|
||||
### F6 — DESIGN. Plan-always is the desired model (operator directive).
|
||||
|
||||
The operator wants: the first thing the agent does is make a plan, even when
|
||||
actions are read-only and need no user approval. This supersedes the SOUL.md
|
||||
degenerate-case carve-out. A one-step plan ("Inspect X, report, write back")
|
||||
is acceptable for trivial questions, but `propose_plan` is mandatory.
|
||||
|
||||
### F7 — EVAL. Eval harness can't express iteration or plan-always.
|
||||
|
||||
- `proposes_plan_once` (`manifest.go:91`) counts total across the whole
|
||||
transcript → a 2-iteration session legitimately calling `propose_plan`
|
||||
twice would **FAIL**. There is no per-turn or "plan generation count"
|
||||
assertion.
|
||||
- `no_rerun` (`manifest.go:32`, not yet implemented as a kind but documented)
|
||||
asserts `run` NOT called after the followup → directly conflicts with an
|
||||
iterative follow-up that needs to run.
|
||||
- No assertion for "session reopened from `done` → `executing`" or "a second
|
||||
plan generation was created."
|
||||
- The manifest supports only **one** `followup` field (`manifest.go:14`),
|
||||
so multi-turn iteration beyond 2 turns isn't expressible.
|
||||
|
||||
---
|
||||
|
||||
## Improvement plan (prioritized)
|
||||
|
||||
### P1 — Make plan-first structural (BLOCKER, ships with P2)
|
||||
|
||||
Goal: every task proposes a plan before any `run`, read-only or not. No
|
||||
SOUL.md-only enforcement.
|
||||
|
||||
1. **Add a `session_has_plan` gate in the `run` handler.**
|
||||
In `internal/mcp/server.go` run(), before the read-only fast path
|
||||
(`server.go:1312`) and the plan/assent windows, check whether
|
||||
`session_plan_steps` has any row for this session. If `sessionID != ""`
|
||||
and no plan exists, refuse:
|
||||
`"No plan for this session. Call set_goal then propose_plan before run — even read-only tasks require a one-step plan."`
|
||||
Mirror D.1's refusal pattern (`tasks.go:313`). This makes plan-first a
|
||||
hard gate, not prose. Read-only commands still auto-execute once a plan
|
||||
exists (they're read-only); the gate is about *ordering*, not approval.
|
||||
2. **Strip the degenerate-case carve-out from SOUL.md.**
|
||||
- Remove `SOUL.md:51-53`'s "degenerate case" sentence.
|
||||
- Rewrite `SOUL.md:108-115` ("Every chat is a task") to: every task
|
||||
proposes a plan; a one-step plan is fine for trivial questions but
|
||||
`propose_plan` is mandatory; only a pure-DB Q&A that calls *no* `run`
|
||||
may skip the plan (still call `set_goal` + `complete_task`).
|
||||
3. **Decouple the plan window from approval (fixes F1).**
|
||||
- Remove `openPlanWindow` from the `set_goal` handler (`tasks.go:179-183`).
|
||||
`set_goal` records the goal + sets status only.
|
||||
- Open the plan window only on approval: the chat-assent grant
|
||||
(`agent.go:318`) and the explicit-approve path (`agent.go:331`). This
|
||||
restores propose → approve → execute for `config_mutation` steps.
|
||||
- Read-only steps need no approval and no window — they auto-run because
|
||||
they're read-only, not because a window is open.
|
||||
|
||||
**Severity:** blocker. **Files:** `tasks.go:171-184`, `store.go:455-481`,
|
||||
`server.go:1312-1345`, `nomos/SOUL.md:6-53,108-115`.
|
||||
|
||||
### P2 — Support iterative follow-ups (BLOCKER, ships with P1)
|
||||
|
||||
Goal: a completed session can be reopened by a follow-up, and the agent can
|
||||
propose a *new* plan for the new sub-task. Iteration, not re-execution.
|
||||
|
||||
1. **Add `reopenSession` on first follow-up after completion.**
|
||||
In `main.go` chat handler, when `sessionID != ""` and the session is
|
||||
already terminal (`done`/`failed`), flip status back to `executing`,
|
||||
clear `outcome`/`summary`, and stamp `last_active_at`. Do this in the
|
||||
handler (not in `set_goal`) so the reopen happens even if the agent's
|
||||
first action is a tool call rather than `set_goal`. Emit a
|
||||
`task.reopened` event for the panel.
|
||||
2. **Clear prior plan steps on reopen, bump generation.**
|
||||
Extend `reopenSession` to mark all `session_plan_steps` for the session
|
||||
as `replaced` (a status already recognized by `updatePlanStep:612`) and
|
||||
delete the `nomos:plan:<session>` autonomy key. The next `propose_plan`
|
||||
then sees `anyStarted=false` (no non-pending rows) and takes the fresh
|
||||
path with `generation = MAX(generation)+1`. This gives the panel a clean
|
||||
new plan list while preserving the prior plan's history (the `replaced`
|
||||
rows + generation counter) for audit.
|
||||
- Alternative considered: delete the rows outright. Rejected — the
|
||||
`replaced` status + generation column already exist for exactly this
|
||||
and preserve the audit trail.
|
||||
3. **Fix the `errPlanInFlight` refusal text to point at the reopen path.**
|
||||
`tasks.go:240` currently says "Re-propose only if the operator explicitly
|
||||
asks" with no way to do it. After P2.2 the operator's follow-up *is* the
|
||||
explicit ask — the reopen clears the in-flight flag. Update the text to:
|
||||
`"A plan from a prior turn is complete. If the operator's new message is a follow-up sub-task, the session has been reopened — propose a fresh plan for it."`
|
||||
4. **SOUL.md: document iteration.** Add a "7. ITERATE" step to the task flow:
|
||||
a completed session accepts a follow-up as a new sub-task; call
|
||||
`set_goal` (new goal) → `propose_plan` (new generation) → execute. Do not
|
||||
re-open the old plan.
|
||||
|
||||
**Severity:** blocker. **Files:** `main.go:217-241`, `store.go` (new
|
||||
`reopenSession`), `tasks.go:240`, `nomos/SOUL.md`.
|
||||
|
||||
### P3 — Persist per-turn reasoning, not just final summary (FRICTION)
|
||||
|
||||
Goal: reload shows what the operator saw live; LLM replay on resume is
|
||||
faithful.
|
||||
|
||||
1. **Insert one assistant row per turn, not one per session.**
|
||||
In `main.go:252-310` and `continue.go:190-312`, insert a new row when a
|
||||
fresh `text`/`tool_use` cycle begins rather than overwriting the same
|
||||
placeholder. Keep the placeholder for the *current* turn only.
|
||||
2. **Accumulate text deltas instead of overwriting `finalText`.**
|
||||
`agent.go:360` emits `text_delta`; the `persist` closure should append
|
||||
into a `textParts []string` and join on `done`, not replace `finalText`
|
||||
on each `text` event (`agent.go:410`). Intermediate reasoning between
|
||||
tool calls is then preserved in the row's `text` field.
|
||||
3. **Truncate per-row tool results** (already done by
|
||||
`truncateToolResults`, `store.go:129`) — verify the cap is sane for the
|
||||
multi-row case.
|
||||
|
||||
**Severity:** friction. **Files:** `main.go:245-310`, `continue.go:190-312`,
|
||||
`store.go:123-159`, `agent.go:360,410`.
|
||||
|
||||
### P4 — Expand the read-only allowlist (FRICTION)
|
||||
|
||||
1. **Add to `readOnlyLeadPattern`** (`command.go:69-78`): `find`, `tree`,
|
||||
`locate`, `systemctl (list-units|list-unit-files|list-timers|show)`,
|
||||
`rclone (ls|lsl|md5|check)`, `timedatectl`, `hostnamectl`, `systemd-analyze`.
|
||||
2. **Add unit cases to `command_test.go`** for the exact
|
||||
`find /var/log/rclone-backup/ -name 'runs.jsonl'` command from this
|
||||
session, plus a compound `ls -lt … && tail … && find …` case.
|
||||
|
||||
**Severity:** friction. **Files:** `internal/policy/command.go:69-78`,
|
||||
`internal/policy/command_test.go`.
|
||||
|
||||
### P5 — Extend the eval harness for plan-always + iteration (F7)
|
||||
|
||||
Goal: P1 and P2 can't regress silently; the harness can express the
|
||||
scenarios the operator cares about.
|
||||
|
||||
1. **New assertion kinds** (`manifest.go` `scoreOne`):
|
||||
- `proposes_plan` — `propose_plan` called >= 1 time (plan-always; replaces
|
||||
the carve-out-dependent `no_propose_plan` for the new model).
|
||||
- `plan_before_run` — the first `run` call's transcript index is strictly
|
||||
greater than the first `propose_plan` index (ordering gate). Requires
|
||||
`transcript` to expose per-call message index (add a helper).
|
||||
- `plan_generations` — the persisted plan has exactly `value` distinct
|
||||
`generation` values in `session_plan_steps` (1 for single-task, 2 for
|
||||
one iteration). Needs a new fetch in `fetchTranscript` hitting
|
||||
`/sessions/{id}/plan` (already exists, returns `steps`).
|
||||
- `reopens_session` — session went `done` → `executing` between the
|
||||
prompt and followup turns. Needs `waitForTurn` to capture the
|
||||
mid-run status, or a new `/sessions/{id}/history` endpoint; simplest
|
||||
is to snapshot status after the prompt turn and assert it was `done`
|
||||
before sending the followup.
|
||||
- `no_rerun` is **removed** (it conflicts with iteration); replace
|
||||
usages with `plan_generations`.
|
||||
2. **Multi-turn follow-ups.** Change `conversation.Followup string`
|
||||
(`manifest.go:14`) to `Followups []string` and loop in `main.go:128-139`,
|
||||
calling `waitForTurn` after each. Backward-compatible: a scalar
|
||||
`followup` still parses by adding a YAML unmarshaler alias, or just
|
||||
migrate existing manifests (there are none in-repo — `evals/` is empty).
|
||||
3. **New manifest files** under `evals/`:
|
||||
- `plan-always-readonly.yaml` — a read-only question that *would* have
|
||||
been a degenerate case under the old SOUL. Asserts `proposes_plan`,
|
||||
`plan_before_run`, `completes`, `writes_back`.
|
||||
- `iteration-followup.yaml` — prompt completes a read-only task; followup
|
||||
asks the agent to *fix* what it found (config_mutation). Asserts
|
||||
`plan_generations: 2`, `reopens_session`, `completes`,
|
||||
`no_duplicate_complete` (per-turn — may need a per-turn variant).
|
||||
- `iteration-readonly.yaml` — two read-only sub-tasks back-to-back.
|
||||
Asserts `plan_generations: 2`, `proposes_plan` (>=2),
|
||||
`max_run_calls` bounded.
|
||||
- `no-plan-no-run.yaml` — a pure-DB Q&A ("list all LXCs"). Asserts
|
||||
`no_run`, `no_propose_plan` (the only remaining carve-out), `completes`.
|
||||
|
||||
**Severity:** friction (blocks regression detection for P1/P2).
|
||||
**Files:** `cmd/nomos/eval/manifest.go`, `cmd/nomos/eval/main.go`, new
|
||||
`evals/*.yaml`.
|
||||
|
||||
### P6 — Differentiate task classes in `set_goal` guidance (COSMETIC, F5)
|
||||
|
||||
Once P1 lands, `set_goal`'s return text (`tasks.go:184`) should say: "Next:
|
||||
gather context with read-only tools, then `propose_plan` (mandatory, even
|
||||
for read-only tasks — a one-step plan is fine). Do not call `run` before
|
||||
`propose_plan`." Drop the "Do not call run" absolute since read-only `run`
|
||||
is valid *after* a plan exists.
|
||||
|
||||
**Severity:** cosmetic. **Files:** `tasks.go:184`.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
|
||||
- **Ship together:** P1 (plan-first gate) + P2 (iteration). P1 without P2
|
||||
makes every completed session un-reopenable; P2 without P1 leaves the
|
||||
approval-free `config_mutation` hole.
|
||||
- **P5 (evals) lands with P1/P2** as the regression net.
|
||||
- **P3 (reasoning persistence) and P4 (read-only allowlist)** are
|
||||
independent and can ship in the same change or after.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go test ./cmd/nomos/... ./internal/policy/...` — new unit tests for the
|
||||
plan gate (P1.1), reopen + generation bump (P2.2), read-only allowlist
|
||||
(P4.2). **DONE 2026-07-15: all pass.**
|
||||
- `go build ./...` + `go vet ./...` — **DONE 2026-07-15: clean.**
|
||||
- `go run ./cmd/nomos/eval -manifest evals/*.yaml` against a live nomos —
|
||||
all four new manifests PASS. **Pending: requires live fleet + credits.**
|
||||
- Manual: replay the Proton Drive prompt, confirm a plan is proposed and
|
||||
the read-only `run`s execute without approval; send "now fix the `repos`
|
||||
failure" as a follow-up, confirm a second plan generation is created and
|
||||
the session reopens. **Pending: requires live fleet.**
|
||||
|
||||
## Implementation log — 2026-07-15
|
||||
|
||||
All P1–P6 implemented in one change. VERSION bumped 0.6.0 → 0.7.0 (minor:
|
||||
new features).
|
||||
|
||||
### What landed
|
||||
|
||||
- **P1 plan-first gate:** `internal/mcp/server.go` — new `sessionHasPlan`
|
||||
helper + gate at the top of `classifyAndGate` (before the dedup check).
|
||||
Refuses `run` with a directive when no plan exists for the session.
|
||||
- **P1 plan window decoupled:** `cmd/nomos/tasks.go` — `openPlanWindow`
|
||||
removed from `set_goal`. `cmd/nomos/store.go` — `openPlanWindow` func
|
||||
deleted, `proposePlan` no longer sets `nomos:plan:<session>`. `server.go`
|
||||
— `planWindowActive` func + its check block deleted. The assent window
|
||||
(opened only on operator approval in `agent.go:317,333`) is the sole
|
||||
gate for `config_mutation` auto-run.
|
||||
- **P1 SOUL.md:** degenerate-case carve-out stripped (§6, "Every chat is a
|
||||
task"). Replaced with "propose_plan is mandatory for any task that calls
|
||||
run — even read-only." Pure-DB Q&A (no `run`) is the only remaining
|
||||
carve-out.
|
||||
- **P2 reopenSession:** `cmd/nomos/store.go` — new `reopenSession` flips
|
||||
status `done`/`failed` → `executing`, marks all plan steps as `replaced`,
|
||||
clears outcome/summary, emits `task.reopened` event.
|
||||
- **P2 caller:** `cmd/nomos/main.go` — chat handler calls `reopenSession`
|
||||
before `touchSession` on every follow-up (no-op if session is still
|
||||
active).
|
||||
- **P2 proposePlan fix:** `anyStarted` check excludes `replaced`; DELETE
|
||||
only pending steps (replaced kept for generation counter + audit). New
|
||||
steps start at `max(seq)` (no collisions across generations).
|
||||
- **P2 errPlanInFlight text:** updated to mention the reopen path.
|
||||
- **P2 SOUL.md:** new "7. ITERATE" step documents the follow-up flow.
|
||||
- **P3 reasoning persistence:** `cmd/nomos/agent.go` — emits `text` event
|
||||
for intermediate reasoning (text + tool calls in same iteration).
|
||||
`cmd/nomos/main.go` + `cmd/nomos/continue.go` — `textParts []string`
|
||||
accumulator joins with `\n\n` instead of overwriting `finalText`.
|
||||
- **P4 read-only allowlist:** `internal/policy/command.go` — added `find`,
|
||||
`tree`, `locate`, `systemctl list-timers/list-unit-files/show`,
|
||||
`timedatectl`, `hostnamectl`, `systemd-analyze`, `rclone ls/lsl/md5sum/
|
||||
check/cryptcheck`. `command_test.go` — 11 new read-only cases + the
|
||||
exact compound from session `d0d562e0`.
|
||||
- **P5 eval harness:** `cmd/nomos/eval/manifest.go` — new assertion kinds
|
||||
(`proposes_plan`, `plan_before_run`, `plan_generations`); `Followup`
|
||||
→ `Followups []string` (backward-compat via `followups()` method).
|
||||
`cmd/nomos/eval/main.go` — multi-turn followup loop; `fetchTranscript`
|
||||
also fetches `/sessions/{id}/plan`; `distinctGenerations()` helper.
|
||||
Four manifests under `evals/`: `plan-always-readonly.yaml`,
|
||||
`iteration-followup.yaml`, `iteration-readonly.yaml`, `no-plan-no-run.yaml`.
|
||||
- **P6 set_goal text:** updated in P1.3 to say "propose_plan (mandatory —
|
||||
even read-only tasks need a one-step plan; the run handler refuses
|
||||
without one). Do not call run before propose_plan."
|
||||
235
plans/done/2026-07-15-whatsapp-session-audit.md
Normal file
235
plans/done/2026-07-15-whatsapp-session-audit.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# 2026-07-15 — WhatsApp session audit: approvals, stuck indicator, stale executions
|
||||
|
||||
**Status:** Done — 2026-07-15. P1–P5 implemented; build + tests + vet pass.
|
||||
Session audit of the WhatsApp bridge
|
||||
investigation (`20757eb9`) following the plan-first + iteration deploy.
|
||||
Four issues reported by the operator, each traced to a distinct root cause.
|
||||
|
||||
## Session under audit
|
||||
|
||||
| Session | Goal | Messages | Outcome |
|
||||
|---|---|---|---|
|
||||
| `20757eb9` | Diagnose and fix the Matrix WhatsApp bridge (stopped delivering messages) | 3 (1 user, 2 assistant) | success — image was 3 months old, `docker compose pull` fixed it |
|
||||
|
||||
The agent correctly diagnosed a 405 protocol-version rejection, pulled the
|
||||
latest image, and the bridge reconnected. The structural fixes all worked
|
||||
(plan-first gate refused the first `run` before `propose_plan`, plan was
|
||||
proposed, writeback happened, `complete_task` closed the loop). But the UX
|
||||
around the execution was wrong.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### F1 — Two approvals instead of one (BLOCKER, misclassification + prose)
|
||||
|
||||
**What happened:** The agent proposed a 6-step plan and immediately started
|
||||
executing steps 1-2 (read-only inspection: `docker compose logs`,
|
||||
`docker compose ps`). Both `run` calls were classified as `config_mutation`
|
||||
and queued for individual approval. The operator saw two approval cards
|
||||
instead of one plan-level approval.
|
||||
|
||||
**Root cause A — `docker compose` subcommands missing from read-only
|
||||
allowlist.** `internal/policy/command.go:69-78` has `docker\s+(ps|images|
|
||||
inspect|logs|version|info|stats)` but NOT `docker compose` subcommands.
|
||||
`docker compose logs` and `docker compose ps` are read-only inspection
|
||||
verbs that the classifier escalates to `config_mutation`. This is the same
|
||||
class of bug as the `find` omission from the Proton Drive audit (P4).
|
||||
|
||||
**Root cause B — agent didn't stop after proposing.** The `propose_plan`
|
||||
return text says "If any step is config_mutation/destructive, STOP and wait
|
||||
for operator approval." The agent ignored this — it started executing in the
|
||||
same turn. This is prose enforcement, not structural. Combined with root
|
||||
cause A, the read-only steps generated approval cards.
|
||||
|
||||
### F2 — Agent indicator stuck at "Approval: Check WhatsApp bridge..." (FRICTION)
|
||||
|
||||
**What happened:** After the session completed (status=`done`), the agent
|
||||
indicator at the bottom of the chat stayed stuck showing "Approval: Check
|
||||
WhatsApp bridge container status on elementsynapse" with a spinner.
|
||||
|
||||
**Root cause:** `web/src/lib/stores/activity.ts:131-150` creates an
|
||||
`approval` entry with `status: 'running'` whenever a tool result contains
|
||||
"requires approval." This entry is **never transitioned to `done`** — the
|
||||
derived store rebuilds from messages on every poll, but the approval entry
|
||||
is always set to `status: 'running'` (line 146). The `AgentIndicator`
|
||||
(`Chat.svelte:153`) shows the first `running` entry from `$activityLog`,
|
||||
so it latches onto the stale approval entry and never clears.
|
||||
|
||||
There's no mechanism to check whether the execution has actually completed
|
||||
— the activity store derives purely from tool-call text, not execution
|
||||
status from the API.
|
||||
|
||||
### F3 — Green "Completed in 1s on lxc:..." boxes in chat (COSMETIC)
|
||||
|
||||
**What happened:** Green success cards (`InlineApproval.svelte:154-163`)
|
||||
rendered inline in the chat message stream for each completed execution.
|
||||
|
||||
**Root cause:** `Chat.svelte:144-146` renders `<InlineApproval>` inside
|
||||
each message bubble when `msg.pendingApprovals.length > 0`. The
|
||||
`InlineApproval` component shows the full approval lifecycle (pending →
|
||||
running → completed/failed) inline in the chat. The operator considers
|
||||
this noise — execution results belong in the activity sidebar, not in the
|
||||
chat stream. The chat should show the agent's text + tool call summary, not
|
||||
approval UX.
|
||||
|
||||
### F4 — 98 stale non-terminal executions (COSMETIC, ops debt)
|
||||
|
||||
**What happened:** 98 executions in non-terminal states
|
||||
(39 `running`, 19 `pending_approval`, 3 `approved`, 37 more `running`
|
||||
orphaned) from eval testing.
|
||||
|
||||
**Breakdown:**
|
||||
- 39 `running` executions from `apt_upgrade:audit` actions — these were
|
||||
created by the MCP `run` handler, then the MCP call timed out (30s
|
||||
context deadline), leaving the execution in `running` state forever.
|
||||
Not linked to any session (orphaned).
|
||||
- 19 `pending_approval` — config_mutation `run` calls that were queued for
|
||||
approval but never approved/denied (eval sessions that completed without
|
||||
resolving them).
|
||||
- 3 `approved` — approved but never executed (the execution dispatch
|
||||
failed or timed out).
|
||||
|
||||
**Root cause:** No startup or periodic cleanup of stale executions. The
|
||||
`run` handler creates an execution entity BEFORE attempting SSH — if the
|
||||
SSH call times out or the MCP connection drops, the execution is
|
||||
orphaned in `running` state. `completeTask` cancels pending approvals for
|
||||
its own session, but nothing cleans up orphaned executions or old
|
||||
sessions' leftovers.
|
||||
|
||||
---
|
||||
|
||||
## Improvement plan
|
||||
|
||||
### P1 — Add `docker compose` read-only subcommands to allowlist
|
||||
|
||||
**Fix:** `internal/policy/command.go` — add to `readOnlyLeadPattern`:
|
||||
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b`.
|
||||
Do NOT add `docker compose exec` or `docker compose run` — these execute
|
||||
arbitrary commands and must stay gated.
|
||||
|
||||
Add unit test case: `"docker compose logs --tail=100"` → `read_only`.
|
||||
|
||||
**Severity:** blocker (directly caused the two-approval issue).
|
||||
**Files:** `internal/policy/command.go:69-78`, `command_test.go`.
|
||||
|
||||
### P2 — Fix stuck agent indicator
|
||||
|
||||
**Fix:** `web/src/lib/stores/activity.ts:131-150` — the approval entries
|
||||
are always `status: 'running'` and never transition. Two options:
|
||||
|
||||
**Option A (recommended): Remove approval entries from activityLog
|
||||
entirely.** They're already rendered as `InlineApproval` cards in the chat
|
||||
(or, after P3, in the Operations page). Duplicating them in the activity
|
||||
log causes the stuck indicator — the activity store derives from tool-call
|
||||
text, not execution status, so it can't know when the execution completed.
|
||||
Removing them means `AgentIndicator` won't latch onto stale approval
|
||||
entries.
|
||||
|
||||
**Option B: Fetch execution status.** When building approval entries,
|
||||
call `getExecution(execId)` to check the real status. This is more
|
||||
correct but adds async API calls to a synchronous derived store — would
|
||||
require restructuring the store to be async or pre-fetching statuses.
|
||||
|
||||
**Decision:** Option A — simpler, eliminates the bug class. If the
|
||||
operator wants approval status in the activity sidebar, that's a separate
|
||||
feature that should use the REST `/approvals` endpoint (already used by
|
||||
`context.ts` and `Ops.svelte`), not text parsing of tool results.
|
||||
|
||||
**Severity:** friction.
|
||||
**Files:** `web/src/lib/stores/activity.ts:131-150`.
|
||||
|
||||
### P3 — Move InlineApproval out of chat, into activity sidebar
|
||||
|
||||
**Fix:** Remove `<InlineApproval>` from `Chat.svelte:144-146`. The chat
|
||||
stream shows only the agent's text + `ToolCallGroup` (the compact tool
|
||||
counter). Approval UX (Approve/Deny buttons, completed/failed cards)
|
||||
moves to the Operations page (already has it via `Ops.svelte`) and/or a
|
||||
dedicated approval panel in the sidebar.
|
||||
|
||||
The `pendingApprovals` field on `ChatMessage` can stay (for counting
|
||||
badges in the session rail), but the inline rendering is removed.
|
||||
|
||||
**Migration:** `InlineApproval.svelte` is not deleted — it's reused in
|
||||
the Operations page or a new sidebar approval panel. The component's
|
||||
props (`PendingApproval[]`) and API (`getExecution`, `decideApproval`)
|
||||
stay the same.
|
||||
|
||||
**Severity:** cosmetic.
|
||||
**Files:** `web/src/pages/Chat.svelte:144-146`.
|
||||
|
||||
### P4 — Stale execution cleanup
|
||||
|
||||
**Fix:** Add a startup cleanup + periodic sweep in nomos:
|
||||
|
||||
1. **Startup cleanup:** on `nomos serve` boot, mark all non-terminal
|
||||
executions older than 1 hour as `cancelled` with result
|
||||
`{"message": "cleaned up at startup — stale from prior session"}`.
|
||||
This handles the 98 stale executions from eval testing.
|
||||
|
||||
2. **Run handler fix:** in `internal/mcp/server.go` `classifyAndGate`,
|
||||
the execution entity is created (line 1284-1293) BEFORE the SSH call.
|
||||
If the SSH call fails or times out, the execution is already marked
|
||||
`running` but never transitions. The error paths (lines 1315-1321,
|
||||
1333-1340, etc.) already mark `failed` — but the MCP client timeout
|
||||
(30s, in `agent.go`'s `client.callTool`) kills the connection before
|
||||
the error path runs. Fix: move the execution entity creation to AFTER
|
||||
the SSH call succeeds, or add a `running` → `failed` timeout sweep.
|
||||
|
||||
3. **Periodic sweep:** add a 5-minute timer (like the continuation
|
||||
worker) that marks executions in `running` state for more than 10
|
||||
minutes as `failed` with result `{"message": "execution timed out"}`.
|
||||
This catches orphaned executions that the run handler didn't clean up.
|
||||
|
||||
**Severity:** cosmetic (ops debt, not a functional bug).
|
||||
**Files:** `cmd/nomos/main.go` (startup), `internal/mcp/server.go`
|
||||
(run handler), `cmd/nomos/continue.go` (periodic sweep).
|
||||
|
||||
### P5 — Enforce "stop after proposing a plan with config_mutation steps"
|
||||
|
||||
**Fix:** This is the structural enforcement gap behind the "agent didn't
|
||||
stop after proposing" behavior. The `propose_plan` return text says "STOP
|
||||
and wait" but nothing enforces it. Two options:
|
||||
|
||||
**Option A (structural):** In the `run` handler (`classifyAndGate`), after
|
||||
the plan-first gate, check if the plan has any `config_mutation` steps
|
||||
AND no assent window is active. If so, refuse the `run` with "This plan
|
||||
has config_mutation steps — wait for operator approval before executing."
|
||||
This would force the agent to stop after proposing, but it would also
|
||||
block the legitimate case where the operator already said "go ahead" (the
|
||||
assent window would be active, so the check would pass).
|
||||
|
||||
**Option B (prose):** Strengthen the `propose_plan` return text and
|
||||
SOUL.md to be more directive. This is what we've been doing — it works
|
||||
for strong models but not for weaker ones.
|
||||
|
||||
**Decision:** Option A — structural enforcement. The check is simple
|
||||
(assent window active?) and catches the exact case where the agent
|
||||
proposes a plan with config_mutation steps and starts executing without
|
||||
approval. Read-only steps still auto-execute (they don't need the
|
||||
assent window).
|
||||
|
||||
**Severity:** friction (prevents the two-approval UX, but doesn't block
|
||||
functionality).
|
||||
**Files:** `internal/mcp/server.go` `classifyAndGate`, `nomos/SOUL.md`.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
|
||||
- **P1** (docker compose allowlist) is independent — ship immediately.
|
||||
- **P2** (stuck indicator) + **P3** (inline approval removal) ship
|
||||
together — both touch the chat rendering surface.
|
||||
- **P4** (stale cleanup) is independent — ship anytime.
|
||||
- **P5** (config_mutation enforcement) depends on P1 (the allowlist fix
|
||||
reduces false config_mutation classifications) — ship after P1.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go test ./internal/policy/...` — new `docker compose logs` read-only
|
||||
test case.
|
||||
- Manual: replay the WhatsApp bridge prompt, confirm a single plan-level
|
||||
approval (not two), no stuck indicator, no green boxes in chat.
|
||||
- `docker exec oikos-postgres-1 psql -U oikos oikos -c "SELECT COUNT(*)
|
||||
FROM executions WHERE status NOT IN ('completed','failed','cancelled')"`
|
||||
→ 0 after the startup cleanup runs.
|
||||
@@ -12,8 +12,12 @@ went sideways, open an investigation.
|
||||
| 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 — 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-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — `request_execution` enum retired (60effcb); only auto-act revival (item 10) still open |
|
||||
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
||||
| 2026-07-14 | [Session reliability & UX audit](2026-07-14-session-reliability-and-ux-audit.md) | Done — all 21 fixes deployed |
|
||||
| 2026-07-14 | [Tool timeline in sidebar](2026-07-14-tool-timeline-sidebar.md) | Done — deployed v0.3.2 |
|
||||
| 2026-07-14 | [Unified agent activity indicator](2026-07-14-unified-agent-indicator.md) | Done — deployed v0.3.3 |
|
||||
| 2026-07-14 | [Post-fix session remainders: empty responses & plan drift](2026-07-14-post-fix-session-remainders.md) | Done — all 18 fixes shipped, eval-validated (4/4 golden evals pass), committed (337d577 + 3de359b + dd3076a), deployed v0.5.3. OIDC token-refresh fix (PM) also shipped (3b98097) |
|
||||
|
||||
## Done
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Oikos — Control Room</title>
|
||||
<title>Oikos</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
||||
@@ -11,6 +14,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
(function(){try{var t=localStorage.getItem('oikos-theme');if(!t){t=window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}
|
||||
if(t==='dark')document.documentElement.classList.add('dark')}catch(e){}})()
|
||||
</script>
|
||||
<script src="/wails/runtime.js"></script>
|
||||
<script>window.__OIKOS_CONFIG__ = {};</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -10,18 +10,19 @@
|
||||
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 { currentTask } from '$lib/stores/workspace'
|
||||
import { isConfigured } from '$lib/config'
|
||||
import { truncateMiddle } from '$lib/utils'
|
||||
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'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { VERSION } from '$lib/version'
|
||||
import { Toaster } from '$lib/components/ui/sonner'
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
@@ -29,6 +30,8 @@
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
import PaletteIcon from '@lucide/svelte/icons/palette'
|
||||
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
|
||||
|
||||
let page = $state('overview')
|
||||
let routeParam = $state('')
|
||||
@@ -72,8 +75,12 @@
|
||||
location.hash = '#/' + p
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
toggleTheme()
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
|
||||
{ id: 'overview', label: 'Tasks', icon: ListTodoIcon },
|
||||
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
|
||||
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
|
||||
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
|
||||
@@ -99,11 +106,11 @@
|
||||
<Sidebar.MenuButton
|
||||
class="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
onclick={() => navigate('overview')}
|
||||
tooltipContent="Oikos"
|
||||
tooltipContent={`Oikos ${VERSION}`}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<svg viewBox="0 0 91 100" class="!size-5 shrink-0 fill-white" aria-hidden="true" role="img">
|
||||
<svg viewBox="0 0 91 100" class="!size-5 shrink-0" fill="var(--primary)" aria-hidden="true" role="img">
|
||||
<title>Oikos</title>
|
||||
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
|
||||
</svg>
|
||||
@@ -112,6 +119,9 @@
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
<div class="group-data-[collapsible=icon]:hidden px-2.5 pb-1">
|
||||
<span class="text-[11px] text-muted-foreground select-none">{VERSION}</span>
|
||||
</div>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
@@ -167,6 +177,16 @@
|
||||
<PanelRightIcon />
|
||||
<span>Chat drawer</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={cycleTheme}
|
||||
title="Cycle theme"
|
||||
>
|
||||
<PaletteIcon />
|
||||
<span>{THEME_LABELS[getTheme()]}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -185,35 +205,15 @@
|
||||
<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('overview')}>Overview</button>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span class="text-base font-medium">Conversation</span>
|
||||
{@const goalText = $currentTask?.goal ? $currentTask.goal.replace(/[*_`~#]|\[.*?\]\(.*?\)/g, '') : 'New Task'}
|
||||
<button type="button" class="shrink-0 text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Tasks</button>
|
||||
<span class="shrink-0 text-muted-foreground">/</span>
|
||||
<span class="min-w-0 flex-1 truncate text-base font-medium" title={goalText}>
|
||||
{truncateMiddle(goalText, 100)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page}</span>
|
||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page === 'overview' ? 'Tasks' : page}</span>
|
||||
{/if}
|
||||
<div class="ms-auto flex items-center gap-2.5">
|
||||
{#if $summary}
|
||||
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
|
||||
<span class="flex items-center gap-1" title="healthy"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
|
||||
<span class="flex items-center gap-1" title="degraded"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
|
||||
<span class="flex items-center gap-1" title="down"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
|
||||
</div>
|
||||
{#if approvalsPending}
|
||||
<button type="button" onclick={() => navigate('ops')}>
|
||||
<Badge variant="destructive" class="cursor-pointer">{approvalsPending} approval{approvalsPending === 1 ? '' : 's'}</Badge>
|
||||
</button>
|
||||
{/if}
|
||||
{#if openSignals}
|
||||
<button type="button" onclick={() => navigate('signals')}>
|
||||
<Badge variant="secondary" class="cursor-pointer">{openSignals} signal{openSignals === 1 ? '' : 's'}</Badge>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<span
|
||||
class="size-2 rounded-full {$connectionState === 'open' ? 'bg-success' : $connectionState === 'connecting' ? 'animate-pulse bg-warning' : 'bg-destructive'}"
|
||||
title="event stream: {$connectionState}"
|
||||
></span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="min-h-0 flex-1 overflow-hidden">
|
||||
{#if page === 'overview'}
|
||||
|
||||
259
web/src/app.css
259
web/src/app.css
@@ -2,62 +2,10 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Neutral gray theme matching shadcn/ui's canonical dark palette (0-chroma
|
||||
OKLCH grays) — the app is dark-only, so :root carries the dark values
|
||||
directly rather than gating behind a .dark class. --success/--warning are
|
||||
Oikos-specific semantic status colors (real health state), kept
|
||||
distinguishable rather than desaturated to match the neutral chrome. */
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
|
||||
/* legacy aliases still referenced by Chat/Sessions/App */
|
||||
--bg: var(--background);
|
||||
--bg-surface: var(--card);
|
||||
--bg-deeper: oklch(0.11 0 0);
|
||||
--bg-hover: var(--secondary);
|
||||
--bg-active: var(--accent);
|
||||
--text: var(--foreground);
|
||||
--text-muted: var(--muted-foreground);
|
||||
/* accent-blue stays a real blue (matches --sidebar-primary) for the few
|
||||
spots that want an interactive "pop" — everything else (buttons,
|
||||
links, focus rings) rides the neutral --primary now. */
|
||||
--accent-blue: var(--sidebar-primary);
|
||||
--accent-green: var(--success);
|
||||
--accent-red: var(--destructive);
|
||||
--accent-orange: var(--warning);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--font-heading: 'Inknut Antiqua', Georgia, serif;
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
@@ -91,25 +39,179 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
}
|
||||
|
||||
/* ── Terracotta Light Theme ── */
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.94 0.02 55);
|
||||
--foreground: oklch(0.18 0.03 45);
|
||||
--card: oklch(0.91 0.025 55);
|
||||
--card-foreground: oklch(0.18 0.03 45);
|
||||
--popover: oklch(0.91 0.025 55);
|
||||
--popover-foreground: oklch(0.18 0.03 45);
|
||||
--primary: oklch(0.55 0.14 45);
|
||||
--primary-foreground: oklch(0.95 0.02 55);
|
||||
--secondary: oklch(0.86 0.03 55);
|
||||
--secondary-foreground: oklch(0.18 0.03 45);
|
||||
--muted: oklch(0.86 0.025 55);
|
||||
--muted-foreground: oklch(0.45 0.04 45);
|
||||
--accent: oklch(0.84 0.035 55);
|
||||
--accent-foreground: oklch(0.18 0.03 45);
|
||||
--destructive: oklch(0.5 0.2 25);
|
||||
--destructive-foreground: oklch(0.95 0.02 55);
|
||||
--border: oklch(0.6 0.08 45);
|
||||
--input: oklch(0.78 0.04 55);
|
||||
--ring: oklch(0.55 0.14 45);
|
||||
--chart-1: oklch(0.55 0.14 45);
|
||||
--chart-2: oklch(0.65 0.1 70);
|
||||
--chart-3: oklch(0.5 0.08 30);
|
||||
--chart-4: oklch(0.6 0.06 90);
|
||||
--chart-5: oklch(0.4 0.04 45);
|
||||
--sidebar: oklch(0.90 0.025 55);
|
||||
--sidebar-foreground: oklch(0.18 0.03 45);
|
||||
--sidebar-primary: oklch(0.55 0.14 45);
|
||||
--sidebar-primary-foreground: oklch(0.95 0.02 55);
|
||||
--sidebar-accent: oklch(0.84 0.035 55);
|
||||
--sidebar-accent-foreground: oklch(0.18 0.03 45);
|
||||
--sidebar-border: oklch(0.6 0.08 45);
|
||||
--sidebar-ring: oklch(0.55 0.14 45);
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
|
||||
--bg: var(--background);
|
||||
--bg-surface: var(--card);
|
||||
--bg-deeper: oklch(0.98 0.01 55);
|
||||
--bg-hover: var(--secondary);
|
||||
--bg-active: var(--accent);
|
||||
--text: var(--foreground);
|
||||
--text-muted: var(--muted-foreground);
|
||||
--accent-blue: var(--primary);
|
||||
--accent-green: var(--success);
|
||||
--accent-red: var(--destructive);
|
||||
--accent-orange: var(--warning);
|
||||
}
|
||||
|
||||
/* ── Carbon Dark Theme ── */
|
||||
.dark {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.6 0.15 220);
|
||||
--chart-3: oklch(0.5 0.1 160);
|
||||
--chart-4: oklch(0.7 0.08 60);
|
||||
--chart-5: oklch(0.45 0.05 320);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
|
||||
--bg: var(--background);
|
||||
--bg-surface: var(--card);
|
||||
--bg-deeper: oklch(0.11 0 0);
|
||||
--bg-hover: var(--secondary);
|
||||
--bg-active: var(--accent);
|
||||
--text: var(--foreground);
|
||||
--text-muted: var(--muted-foreground);
|
||||
--accent-blue: var(--sidebar-primary);
|
||||
--accent-green: var(--success);
|
||||
--accent-red: var(--destructive);
|
||||
--accent-orange: var(--warning);
|
||||
}
|
||||
|
||||
|
||||
/* Terminal-style block cursor — outside @layer so it overrides CodeMirror */
|
||||
.cm-cursor,
|
||||
.cm-cursor-primary {
|
||||
border-left-color: var(--primary) !important;
|
||||
border-left-width: 0.5em !important;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* Tailwind's preflight resets <button> to cursor: default; every button
|
||||
and native interactive element in this app is clickable, so restore
|
||||
the pointer cursor app-wide instead of annotating each one. */
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
/* Theme-aware scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Pointer cursor on all interactive elements */
|
||||
button:not(:disabled),
|
||||
[role='button']:not([aria-disabled='true']),
|
||||
a[href],
|
||||
@@ -117,30 +219,29 @@
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a,
|
||||
[role='link'],
|
||||
[role='tab'],
|
||||
[role='option'],
|
||||
[data-slot='popover-trigger'],
|
||||
[data-slot='toggle-group-item'],
|
||||
[data-slot='alert-dialog-action'],
|
||||
[data-slot='alert-dialog-cancel'],
|
||||
.cm-tooltip-autocomplete [role='option'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-slot='table-container'] {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Session {
|
||||
outcome?: string // success | failure | partial
|
||||
summary?: string
|
||||
entity_id?: string
|
||||
pending_approvals?: number
|
||||
created_at: string
|
||||
last_active_at: string
|
||||
}
|
||||
@@ -51,12 +52,18 @@ export async function deleteSession(sessionId: string): Promise<boolean> {
|
||||
return res.ok
|
||||
}
|
||||
|
||||
export async function resumeSession(sessionId: string): Promise<boolean> {
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/resume`, { method: 'POST' })
|
||||
return res.ok
|
||||
}
|
||||
|
||||
export interface PlanStep {
|
||||
id: string
|
||||
seq: number
|
||||
title: string
|
||||
detail: string
|
||||
status: 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'blocked'
|
||||
status: 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'blocked' | 'replaced'
|
||||
generation?: number
|
||||
execution_id?: string
|
||||
target_slug?: string
|
||||
started_at?: string
|
||||
|
||||
150
web/src/lib/components/ActivityTimeline.svelte
Normal file
150
web/src/lib/components/ActivityTimeline.svelte
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
||||
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
|
||||
let expanded = $state(new Set<string>())
|
||||
|
||||
function toggle(id: string) {
|
||||
if (expanded.has(id)) expanded.delete(id)
|
||||
else expanded.add(id)
|
||||
expanded = new Set(expanded)
|
||||
}
|
||||
|
||||
function typeIcon(type: ActivityEntry['type']) {
|
||||
switch (type) {
|
||||
case 'goal': return MilestoneIcon
|
||||
case 'plan': return ListTodoIcon
|
||||
case 'step_running': case 'step_done': case 'step_failed':
|
||||
case 'tool_running': case 'tool_done': case 'tool_error':
|
||||
return null // use status icon instead
|
||||
case 'knowledge': return SparklesIcon
|
||||
case 'complete': return FlagIcon
|
||||
case 'question': return HelpCircleIcon
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: ActivityEntry['status']) {
|
||||
if (status === 'failed') return 'text-destructive'
|
||||
return 'text-primary'
|
||||
}
|
||||
|
||||
// Tool results often arrive as a JSON string — pretty-print it when it
|
||||
// parses, otherwise fall back to the raw text rather than hiding it.
|
||||
function prettyPrint(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string | null {
|
||||
if (!ts) return null
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if $activityLog.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 px-3 py-8 text-center">
|
||||
<svg viewBox="0 0 64 110" class="h-20 w-auto text-muted-foreground/40" fill="none">
|
||||
<line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" />
|
||||
<circle cx="32" cy="22" r="4" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="32" cy="55" r="4" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<animate attributeName="r" values="4;11;4" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.6;0;0.6" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="32" cy="88" r="4" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</svg>
|
||||
<p class="max-w-[12rem] text-xs leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col py-1">
|
||||
{#each $activityLog as entry, i (entry.id)}
|
||||
{@const isLast = i === $activityLog.length - 1}
|
||||
{@const icon = typeIcon(entry.type)}
|
||||
{@const isOpen = expanded.has(entry.id)}
|
||||
{@const time = formatTime(entry.timestamp)}
|
||||
<div class="relative">
|
||||
<!-- connector line -->
|
||||
{#if !isLast}
|
||||
<div class="absolute left-[17px] top-6 bottom-0 w-px bg-border"></div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 {entry.indent ? 'pl-7' : 'px-3'} py-1.5 text-left text-xs hover:bg-muted/30 cursor-pointer"
|
||||
onclick={() => toggle(entry.id)}
|
||||
>
|
||||
<!-- status icon -->
|
||||
<span class="relative mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full {statusColor(entry.status)}">
|
||||
{#if entry.status === 'running'}
|
||||
<Spinner class="size-3.5" />
|
||||
{:else if entry.status === 'failed'}
|
||||
<CircleXIcon class="size-3.5" />
|
||||
{:else if icon}
|
||||
<svelte:component this={icon} class="size-3" />
|
||||
{:else}
|
||||
<CircleDotIcon class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
<!-- description -->
|
||||
<span class="min-w-0 flex-1 leading-snug {entry.status === 'done' ? 'text-muted-foreground' : ''}">
|
||||
{entry.description}
|
||||
</span>
|
||||
<span class="mt-0.5 shrink-0 text-muted-foreground">
|
||||
{#if isOpen}
|
||||
<ChevronDownIcon class="size-3" />
|
||||
{:else}
|
||||
<ChevronRightIcon class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
<!-- detail -->
|
||||
{#if isOpen}
|
||||
<div class="flex flex-col gap-1.5 pl-8 pr-3 pb-2">
|
||||
<div class="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span class="capitalize">{entry.status}</span>
|
||||
{#if time}<span aria-hidden="true">·</span><span>{time}</span>{/if}
|
||||
{#if entry.toolName}<span aria-hidden="true">·</span><code class="font-mono">{entry.toolName}</code>{/if}
|
||||
</div>
|
||||
{#if entry.args}
|
||||
<div>
|
||||
<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">Called with</p>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.args)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if entry.detail}
|
||||
<div>
|
||||
{#if entry.args}<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">{entry.status === 'failed' ? 'Error' : 'Result'}</p>{/if}
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.detail)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !entry.args && !entry.detail}
|
||||
<p class="text-[10px] text-muted-foreground/70">No further detail for this step.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
42
web/src/lib/components/AgentIndicator.svelte
Normal file
42
web/src/lib/components/AgentIndicator.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityEntry } from '$lib/stores/activity'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { active = false, lastActivity = null as ActivityEntry | null, error = '' }: { active?: boolean; lastActivity?: ActivityEntry | null; error?: string } = $props()
|
||||
|
||||
let done = $state(false)
|
||||
let wasActive = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (active) { done = false; wasActive = true }
|
||||
if (!active && wasActive) {
|
||||
done = true
|
||||
const t = setTimeout(() => { done = false; wasActive = false }, 3000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
})
|
||||
|
||||
const label = $derived.by(() => {
|
||||
if (error) return error
|
||||
if (!active && done) return 'Done'
|
||||
if (lastActivity) return lastActivity.description
|
||||
return 'Agent is thinking…'
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if active || done || error}
|
||||
<div class="flex items-center gap-2 py-2 text-xs transition-opacity {error ? 'text-destructive' : done ? 'text-success opacity-50' : 'text-muted-foreground'}">
|
||||
<span class="shrink-0">
|
||||
{#if error}
|
||||
<XIcon class="size-3" />
|
||||
{:else if done}
|
||||
<CheckIcon class="size-3" />
|
||||
{:else}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{/if}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
171
web/src/lib/components/ConfigBackground.svelte
Normal file
171
web/src/lib/components/ConfigBackground.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { getTheme } from '$lib/stores/theme.svelte'
|
||||
|
||||
interface Particle {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
r: number
|
||||
phase: number
|
||||
pulse: number
|
||||
}
|
||||
|
||||
const COUNT = 90
|
||||
const CONNECT_DIST = 160
|
||||
const MOUSE_RADIUS = 200
|
||||
const MOUSE_FORCE = 0.012
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null)
|
||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||
let particles: Particle[] = []
|
||||
let mouse = { x: -500, y: -500 }
|
||||
let w = 0, h = 0, dpr = 1
|
||||
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
||||
|
||||
function spawn() {
|
||||
particles = Array.from({ length: COUNT }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: (Math.random() - 0.5) * 0.3,
|
||||
r: 1 + Math.random() * 2,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
pulse: 0.4 + Math.random() * 0.6
|
||||
}))
|
||||
}
|
||||
|
||||
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)
|
||||
if (particles.length === 0) spawn()
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!host) return
|
||||
const rect = host.getBoundingClientRect()
|
||||
mouse.x = e.clientX - rect.left
|
||||
mouse.y = e.clientY - rect.top
|
||||
}
|
||||
|
||||
function onPointerLeave() {
|
||||
mouse.x = -500
|
||||
mouse.y = -500
|
||||
}
|
||||
|
||||
function draw(ts: number) {
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
if (!canvas || particles.length === 0) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const t = ts / 1000
|
||||
const dark = getTheme() !== 'light'
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// update + draw particles
|
||||
for (const p of particles) {
|
||||
// autonomous drift
|
||||
p.vx += (Math.sin(t * 0.4 + p.phase) * 0.003) * 0.15
|
||||
p.vy += (Math.cos(t * 0.35 + p.phase) * 0.003) * 0.15
|
||||
|
||||
// mouse interaction
|
||||
const dx = p.x - mouse.x
|
||||
const dy = p.y - mouse.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < MOUSE_RADIUS && dist > 0) {
|
||||
const force = (MOUSE_RADIUS - dist) / MOUSE_RADIUS * MOUSE_FORCE
|
||||
p.vx += (dx / dist) * force * 0.6
|
||||
p.vy += (dy / dist) * force * 0.6
|
||||
}
|
||||
|
||||
// friction + random nudge
|
||||
p.vx *= 0.995
|
||||
p.vy *= 0.995
|
||||
if (Math.random() < 0.003) {
|
||||
p.vx += (Math.random() - 0.5) * 0.04
|
||||
p.vy += (Math.random() - 0.5) * 0.04
|
||||
}
|
||||
|
||||
// wrap
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
if (p.x < -40) p.x = w + 40
|
||||
if (p.x > w + 40) p.x = -40
|
||||
if (p.y < -40) p.y = h + 40
|
||||
if (p.y > h + 40) p.y = -40
|
||||
|
||||
// pulse brightness
|
||||
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
|
||||
ctx.fillStyle = dark
|
||||
? `rgba(140,175,230,${alpha})`
|
||||
: `rgba(60,90,140,${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// connections between nearby particles
|
||||
ctx.lineWidth = 0.6
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const a = particles[i]
|
||||
const b = particles[j]
|
||||
const dx = a.x - b.x
|
||||
const dy = a.y - b.y
|
||||
const dist = dx * dx + dy * dy
|
||||
if (dist < CONNECT_DIST * CONNECT_DIST) {
|
||||
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
|
||||
ctx.strokeStyle = dark
|
||||
? `rgba(140,175,230,${alpha})`
|
||||
: `rgba(60,90,140,${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(b.x, b.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// radial scrim to keep center legible
|
||||
const cx = w / 2, cy = h / 2
|
||||
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.72)`)
|
||||
scrim.addColorStop(0.35, `rgba(${base},0.40)`)
|
||||
scrim.addColorStop(0.65, `rgba(${base},0.08)`)
|
||||
scrim.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = scrim
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
resize()
|
||||
spawn()
|
||||
const ro = new ResizeObserver(() => {
|
||||
resize()
|
||||
spawn()
|
||||
})
|
||||
if (host) ro.observe(host)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerleave', onPointerLeave)
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
ro.disconnect()
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerleave', onPointerLeave)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div bind:this={host} class="absolute inset-0 overflow-hidden bg-background">
|
||||
<canvas bind:this={canvas} class="h-full w-full"></canvas>
|
||||
</div>
|
||||
@@ -437,10 +437,11 @@
|
||||
{#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%)]">
|
||||
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border">
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
viewBox="0 0 {width} {height}"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
|
||||
role="application"
|
||||
aria-label="Entity graph"
|
||||
@@ -451,12 +452,16 @@
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
||||
</pattern>
|
||||
{#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>
|
||||
<rect x="0" y="0" width={width} height={height} fill="url(#dot-grid)" />
|
||||
<g transform="translate({view.x},{view.y}) scale({view.k})">
|
||||
<g>
|
||||
{#each links as link}
|
||||
@@ -522,15 +527,15 @@
|
||||
{#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} />
|
||||
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? 'var(--foreground)' : 'var(--background)'} 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'}
|
||||
fill={isFocus ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
||||
paint-order="stroke"
|
||||
stroke="#0d1117"
|
||||
stroke="var(--background)"
|
||||
stroke-width={3 / view.k}
|
||||
class="pointer-events-none select-none"
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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'
|
||||
import { getTheme } from '$lib/stores/theme.svelte'
|
||||
|
||||
// Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
|
||||
// host places this behind the page with pointer-events:none, so it never
|
||||
@@ -102,6 +102,28 @@
|
||||
let dpr = 1
|
||||
let w = 0
|
||||
let h = 0
|
||||
let dotCanvas: HTMLCanvasElement | null = null
|
||||
let lastDotDark: boolean | null = null
|
||||
|
||||
function drawDots(dark: boolean) {
|
||||
if (!dotCanvas) {
|
||||
dotCanvas = document.createElement('canvas')
|
||||
}
|
||||
dotCanvas.width = Math.round(w * dpr)
|
||||
dotCanvas.height = Math.round(h * dpr)
|
||||
const dctx = dotCanvas.getContext('2d')!
|
||||
dctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
dctx.clearRect(0, 0, w, h)
|
||||
dctx.fillStyle = dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
|
||||
const spacing = 12
|
||||
for (let x = spacing; x < w; x += spacing) {
|
||||
for (let y = spacing; y < h; y += spacing) {
|
||||
dctx.beginPath()
|
||||
dctx.arc(x, y, 0.7, 0, Math.PI * 2)
|
||||
dctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!host) return
|
||||
@@ -118,6 +140,7 @@
|
||||
h = host.clientHeight
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
dotCanvas = null // force redraw on next frame
|
||||
}
|
||||
|
||||
function colorForNode(n: SimNode): string {
|
||||
@@ -145,10 +168,14 @@
|
||||
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'
|
||||
const dark = getTheme() !== 'light'
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
if (lastDotDark !== dark) { dotCanvas = null; lastDotDark = dark }
|
||||
if (!dotCanvas) drawDots(dark)
|
||||
ctx.drawImage(dotCanvas!, 0, 0)
|
||||
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import NetworkIcon from '@lucide/svelte/icons/network'
|
||||
|
||||
let { approvals }: { approvals: PendingApproval[] } = $props()
|
||||
@@ -178,7 +178,7 @@
|
||||
{@const secs = elapsedSeconds(e)}
|
||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<LoaderCircleIcon class="size-4 shrink-0 animate-spin text-warning" />
|
||||
<Spinner class="size-4 shrink-0 text-warning" />
|
||||
<span>
|
||||
{#if p === 'deciding'}
|
||||
Submitting approval…
|
||||
|
||||
@@ -2,29 +2,48 @@
|
||||
import { planSteps } from '$lib/stores/workspace'
|
||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
|
||||
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
|
||||
const done = $derived($planSteps.filter((s) => s.status === 'done').length)
|
||||
const total = $derived($planSteps.length)
|
||||
const pct = $derived(total > 0 ? Math.round((done / total) * 100) : 0)
|
||||
|
||||
// Group steps by generation. The latest generation is shown expanded;
|
||||
// older ones are collapsible.
|
||||
const byGeneration = $derived.by(() => {
|
||||
const groups: Map<number, typeof $planSteps> = new Map()
|
||||
for (const s of $planSteps) {
|
||||
const g = s.generation ?? 1
|
||||
if (!groups.has(g)) groups.set(g, [])
|
||||
groups.get(g)!.push(s)
|
||||
}
|
||||
return Array.from(groups.entries()).sort(([a], [b]) => a - b)
|
||||
})
|
||||
|
||||
const latestGen = $derived(byGeneration.length > 0 ? byGeneration[byGeneration.length - 1][0] : 0)
|
||||
|
||||
let openGens = $state(new Set<number>())
|
||||
|
||||
let sheetSlug = $state<string | null>(null)
|
||||
let sheetOpen = $state(false)
|
||||
|
||||
// Tool calls don't carry a step id, so a step can't be linked to its exact
|
||||
// transcript entry — but its target entity IS known, and EntitySheet already
|
||||
// gives a real, working detail view for any slug. Clicking a step with a
|
||||
// target opens that, rather than a fake "scroll to it" that would silently
|
||||
// no-op for a collapsed tool-call group.
|
||||
function openStep(targetSlug: string | undefined) {
|
||||
if (!targetSlug) return
|
||||
sheetSlug = targetSlug
|
||||
sheetOpen = true
|
||||
}
|
||||
|
||||
function toggleGen(gen: number) {
|
||||
if (openGens.has(gen)) openGens.delete(gen)
|
||||
else openGens.add(gen)
|
||||
openGens = new Set(openGens)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if total > 0}
|
||||
@@ -36,12 +55,30 @@
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {pct}%"></div>
|
||||
</div>
|
||||
|
||||
{#each byGeneration as [gen, steps] (gen)}
|
||||
{@const isLatest = gen === latestGen}
|
||||
{#if byGeneration.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground"
|
||||
onclick={() => toggleGen(gen)}
|
||||
>
|
||||
{#if openGens.has(gen) || isLatest}
|
||||
<ChevronDownIcon class="size-3" />
|
||||
{:else}
|
||||
<ChevronRightIcon class="size-3" />
|
||||
{/if}
|
||||
<span>{isLatest ? 'Current plan' : `Plan v${gen} (replaced)`}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if isLatest || openGens.has(gen)}
|
||||
<ol class="flex flex-col gap-1">
|
||||
{#each $planSteps as step (step.id)}
|
||||
{#each steps as step (step.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 rounded px-1 py-1 text-left text-xs {step.target_slug ? 'hover:bg-muted/50' : 'cursor-default'}"
|
||||
class="flex w-full items-start gap-2 rounded px-1 py-1 text-left text-xs {step.target_slug ? 'hover:bg-muted/50' : 'cursor-default'} {gen !== latestGen ? 'opacity-50' : ''}"
|
||||
onclick={() => openStep(step.target_slug)}
|
||||
>
|
||||
<span class="mt-0.5 shrink-0">
|
||||
@@ -50,8 +87,8 @@
|
||||
{:else if step.status === 'failed'}
|
||||
<CircleXIcon class="size-3.5 text-destructive" />
|
||||
{:else if step.status === 'running'}
|
||||
<LoaderCircleIcon class="size-3.5 animate-spin text-primary" />
|
||||
{:else if step.status === 'skipped'}
|
||||
<Spinner class="size-3.5 text-primary" />
|
||||
{:else if step.status === 'skipped' || step.status === 'replaced'}
|
||||
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
|
||||
{:else if step.status === 'blocked'}
|
||||
<CirclePauseIcon class="size-3.5 text-warning" />
|
||||
@@ -71,6 +108,8 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
|
||||
import { currentSession, streaming } from '$lib/stores/chat'
|
||||
import { currentTask } from '$lib/stores/workspace'
|
||||
import { currentSession, streaming, toolTimeline, type ToolTimelineEntry } from '$lib/stores/chat'
|
||||
import { currentTask, planSteps } from '$lib/stores/workspace'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
|
||||
let digest = $state<SessionDigest | null>(null)
|
||||
let open = $state(false)
|
||||
// Keyed on session id AND status: a task that completes mid-view (via
|
||||
// resumeSession running server-side, with $streaming never true here) must
|
||||
// still refetch once outcome/summary land, not just on session switch.
|
||||
let openTools = $state(false)
|
||||
let loadedKey = $state<string | null>(null)
|
||||
|
||||
// Reload the digest whenever the session changes, the task's status changes
|
||||
// (e.g. it just completed), or a stream finishes — "what did this session
|
||||
// actually do" is only meaningful once executions have had a chance to land.
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = $state(null)
|
||||
|
||||
$effect(() => {
|
||||
const sid = $currentSession
|
||||
const busy = $streaming
|
||||
const status = $currentTask?.status ?? ''
|
||||
if (!sid || busy) return
|
||||
const key = `${sid}:${status}`
|
||||
if (loadedKey === key) return
|
||||
if (loadedKey !== key) {
|
||||
loadedKey = key
|
||||
fetchSessionDigest(sid).then((d) => (digest = d))
|
||||
}
|
||||
if (status !== 'done' && status !== 'failed') {
|
||||
if (!pollTimer) pollTimer = setInterval(() => fetchSessionDigest(sid).then((d) => (digest = d)), 10000)
|
||||
} else {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
return () => {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
})
|
||||
|
||||
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
@@ -36,8 +45,42 @@
|
||||
if (['running', 'approved'].includes(status)) return 'secondary'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
|
||||
const planTotal = $derived($planSteps.length)
|
||||
|
||||
// Group tool timeline entries by message (turn), showing only unique tool names per entry.
|
||||
const toolGroups = $derived.by(() => {
|
||||
const groups: { msgIndex: number; entries: ToolTimelineEntry[] }[] = []
|
||||
for (const e of $toolTimeline) {
|
||||
const last = groups[groups.length - 1]
|
||||
if (last && last.msgIndex === e.msgIndex) {
|
||||
last.entries.push(e)
|
||||
} else {
|
||||
groups.push({ msgIndex: e.msgIndex, entries: [e] })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const toolCount = $derived($toolTimeline.filter((t) => t.type === 'tool_use').length)
|
||||
const runningCount = $derived($toolTimeline.filter((t) => t.type === 'tool_use' && !$toolTimeline.some((r) => r.type === 'tool_result' && r.id === t.id)).length)
|
||||
|
||||
function toolSummary(t: ToolTimelineEntry): string {
|
||||
if (!t.args || typeof t.args !== 'object') return t.name
|
||||
const firstArg = Object.values(t.args as Record<string, unknown>)[0]
|
||||
if (typeof firstArg === 'string') return `${t.name} ${firstArg.slice(0, 40)}`
|
||||
return t.name
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $streaming && $currentSession}
|
||||
<div class="flex items-center gap-2 border-b px-3 py-2 text-xs text-muted-foreground">
|
||||
<Spinner class="size-3 shrink-0 text-primary" />
|
||||
<span>{toolCount} tool{toolCount === 1 ? '' : 's'} · {runningCount} running</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $currentTask?.outcome}
|
||||
<div
|
||||
class="flex items-start gap-2 border-b px-3 py-2 text-xs {$currentTask.outcome === 'failure'
|
||||
@@ -55,6 +98,76 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $planSteps.length > 0}
|
||||
<div class="flex shrink-0 flex-col gap-1 border-b px-3 py-2">
|
||||
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span class="font-semibold uppercase tracking-wider">Plan</span>
|
||||
<span>{planDone}/{planTotal}</span>
|
||||
</div>
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0}%"></div>
|
||||
</div>
|
||||
<ol class="flex flex-col gap-0.5">
|
||||
{#each $planSteps as step (step.id)}
|
||||
<li class="flex items-start gap-1.5 text-[11px] {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
|
||||
<span class="mt-0.5 shrink-0">
|
||||
{#if step.status === 'done'}
|
||||
<CircleCheckIcon class="size-3 text-success" />
|
||||
{:else if step.status === 'running'}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{:else if step.status === 'failed'}
|
||||
<CircleXIcon class="size-3 text-destructive" />
|
||||
{:else}
|
||||
<CircleIcon class="size-3 text-muted-foreground" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="leading-tight">{step.title}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if toolCount > 0}
|
||||
<div class="border-b">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
|
||||
onclick={() => (openTools = !openTools)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
{#if openTools}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
|
||||
<WrenchIcon class="size-3 text-muted-foreground" />
|
||||
Tool activity
|
||||
</span>
|
||||
<span class="text-muted-foreground">{toolCount} call{toolCount === 1 ? '' : 's'}</span>
|
||||
</button>
|
||||
{#if openTools}
|
||||
<div class="flex flex-col gap-0.5 px-3 pb-2 text-xs">
|
||||
{#each toolGroups as group}
|
||||
{@const isLatest = group.msgIndex === toolGroups[toolGroups.length - 1]?.msgIndex}
|
||||
<div class="rounded border px-2 py-1 {isLatest && $streaming ? 'border-primary/30 bg-primary/5' : ''}">
|
||||
{#each group.entries as t (t.id)}
|
||||
<div class="flex items-start gap-1.5 {t.type === 'tool_result' && t.error ? 'text-destructive' : ''}">
|
||||
<span class="mt-0.5 shrink-0">
|
||||
{#if t.type === 'tool_result' && t.error}
|
||||
<CircleXIcon class="size-3 text-destructive" />
|
||||
{:else if t.type === 'tool_result'}
|
||||
<CircleCheckIcon class="size-3 text-success" />
|
||||
{:else}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="font-mono text-[10px] truncate">{toolSummary(t)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if digest && digest.total_executions > 0}
|
||||
<div class="border-b">
|
||||
<button
|
||||
@@ -67,7 +180,7 @@
|
||||
This session
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
||||
{digest.total_executions} action{digest.total_executions === 1 ? '' : 's'}
|
||||
{digest.total_executions} execution{digest.total_executions === 1 ? '' : 's'}
|
||||
{#if digest.knowledge_created.length}
|
||||
<span class="flex items-center gap-0.5 text-primary">
|
||||
<SparklesIcon class="size-3" />{digest.knowledge_created.length}
|
||||
@@ -77,42 +190,19 @@
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="flex flex-col gap-3 px-3 pb-3 text-xs">
|
||||
<div class="flex flex-col gap-2 px-3 pb-3 text-xs">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each Object.entries(digest.by_status) as [status, count]}
|
||||
<Badge variant={statusVariant(status)}>{status} × {count}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if digest.entities_touched.length}
|
||||
<div>
|
||||
<div class="mb-1 text-muted-foreground">Entities touched</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each digest.entities_touched as target}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{target}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each digest.executions as ex}
|
||||
<div class="flex items-start justify-between gap-2 rounded border px-2 py-1">
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono text-[11px] text-muted-foreground">{ex.target}</div>
|
||||
<div class="truncate">{ex.summary || ex.verb}</div>
|
||||
</div>
|
||||
<Badge variant={statusVariant(ex.status)} class="shrink-0">{ex.status}</Badge>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if digest.knowledge_created.length}
|
||||
<div>
|
||||
<div class="mb-1 flex items-center gap-1 text-primary">
|
||||
<SparklesIcon class="size-3" />Learned this session
|
||||
</div>
|
||||
<ul class="list-inside list-disc">
|
||||
<ul class="list-inside list-disc text-muted-foreground">
|
||||
{#each digest.knowledge_created as title}
|
||||
<li>{title}</li>
|
||||
{/each}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
interface Node extends Entity {
|
||||
x?: number
|
||||
@@ -65,6 +66,14 @@
|
||||
let cw = $state(300)
|
||||
let ch = $state(300)
|
||||
|
||||
// This component sits INSIDE one of TaskContextPanel's own resizable slots
|
||||
// (Scope), so — unlike a top-level section — its total budget can change at
|
||||
// any time from outside (dragging the outer Scope/Plan handle), including
|
||||
// while the detail panel below is open. asideHeight tracks that live budget
|
||||
// so detailHeight can self-clamp to it instead of trusting a one-time seed.
|
||||
let asideEl = $state<HTMLElement | null>(null)
|
||||
let asideHeight = $state(300)
|
||||
|
||||
function collectSlugs(value: unknown, out: Set<string>) {
|
||||
if (typeof value === 'string') {
|
||||
const m = value.match(SLUG_RE)
|
||||
@@ -208,6 +217,15 @@
|
||||
return () => ro.disconnect()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!asideEl) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
asideHeight = Math.max(entries[0].contentRect.height, 1)
|
||||
})
|
||||
ro.observe(asideEl)
|
||||
return () => ro.disconnect()
|
||||
})
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
const healthColor: Record<string, string> = {
|
||||
@@ -249,6 +267,51 @@
|
||||
return typeof end === 'object' ? end.slug : end
|
||||
}
|
||||
|
||||
// ─── graph / detail resize ───────────────────────────────────────────
|
||||
// Same drag handle, same feel as TaskContextPanel's Scope/Plan/Activity
|
||||
// split — but the graph side stays flex-1 (always auto-fills whatever's
|
||||
// left) rather than tracking its own pixel number. Only detailHeight is
|
||||
// explicit, and it's continuously clamped against asideHeight (this
|
||||
// component's actual live budget) rather than a value seeded once — so
|
||||
// resizing the OUTER Scope section while the detail panel is open can't
|
||||
// push this panel past its container the way a one-time seed could.
|
||||
const MIN_GRAPH = 80
|
||||
const MIN_DETAIL = 80
|
||||
const HANDLE = 6
|
||||
|
||||
let detailHeight = $state(200)
|
||||
let resizing = $state(false)
|
||||
let resizeStartY = $state(0)
|
||||
let resizeStartH = $state(0)
|
||||
|
||||
function maxDetailHeight(): number {
|
||||
return Math.max(MIN_DETAIL, asideHeight - MIN_GRAPH - HANDLE)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const max = maxDetailHeight()
|
||||
if (detailHeight > max) detailHeight = max
|
||||
})
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
resizeStartY = e.clientY
|
||||
resizeStartH = detailHeight
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!resizing) return
|
||||
const dy = e.clientY - resizeStartY
|
||||
detailHeight = Math.min(maxDetailHeight(), Math.max(MIN_DETAIL, resizeStartH - dy))
|
||||
}
|
||||
function onPointerUp() {
|
||||
resizing = false
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
// ─── drag / select ───────────────────────────────────────────────────
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
@@ -278,7 +341,15 @@
|
||||
node.fy = null
|
||||
sim?.alphaTarget(0)
|
||||
dragState = null
|
||||
if (!moved) selected = selected?.slug === node.slug ? null : node
|
||||
if (!moved) {
|
||||
const wasNull = selected === null
|
||||
const next = selected?.slug === node.slug ? null : node
|
||||
// A reasonable starting size on first open — the clamp effect above
|
||||
// keeps it honest against the live container size from here on, so
|
||||
// this doesn't need to be exact.
|
||||
if (next && wasNull) detailHeight = Math.min(maxDetailHeight(), Math.round(ch * 0.45))
|
||||
selected = next
|
||||
}
|
||||
}
|
||||
|
||||
const selectedRelations = $derived(
|
||||
@@ -299,13 +370,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full min-h-0 flex-col bg-card/40">
|
||||
<div class="flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Session graph</p>
|
||||
{#if nodes.length}
|
||||
<span class="text-[11px] text-muted-foreground">{nodes.length} {nodes.length === 1 ? 'entity' : 'entities'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<aside bind:this={asideEl} class="flex h-full min-h-0 flex-col bg-card/40">
|
||||
{#if nowTouching}
|
||||
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
|
||||
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
|
||||
@@ -349,6 +414,12 @@
|
||||
onpointerup={onUp}
|
||||
onpointercancel={onUp}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width={cw} height={ch} fill="url(#dot-grid)" />
|
||||
<g>
|
||||
{#each links as link}
|
||||
{@const s = endpoint(link.source)}
|
||||
@@ -431,11 +502,26 @@
|
||||
</div>
|
||||
|
||||
{#if selected}
|
||||
<div class="max-h-[55%] shrink-0 space-y-3 overflow-y-auto border-t p-3 text-xs">
|
||||
<!-- resize handle -->
|
||||
<div
|
||||
class="h-1.5 shrink-0 cursor-row-resize border-b hover:bg-primary/30 touch-none"
|
||||
onpointerdown={onPointerDown}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
></div>
|
||||
<div class="shrink-0 space-y-3 overflow-y-auto p-3 text-xs" style="height: {detailHeight}px">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-mono text-sm font-semibold">{selected.slug}</span>
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-sm font-semibold">{selected.slug}</span>
|
||||
<Badge variant="outline">{selected.type}</Badge>
|
||||
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onclick={() => (selected = null)}
|
||||
aria-label="Close entity detail"
|
||||
>
|
||||
<XIcon class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{#if selected.health}
|
||||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat, deleteSession } from '$lib/stores/chat'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2'
|
||||
|
||||
onMount(() => {
|
||||
loadSessions()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
void $currentSession
|
||||
loadSessions()
|
||||
})
|
||||
|
||||
let confirmDelete = $state<string | null>(null)
|
||||
|
||||
function handleDelete(e: MouseEvent, id: string) {
|
||||
e.stopPropagation()
|
||||
if (confirmDelete === id) {
|
||||
deleteSession(id)
|
||||
confirmDelete = null
|
||||
} else {
|
||||
confirmDelete = id
|
||||
// Hide confirmation after 3s
|
||||
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(sessionId: string) {
|
||||
confirmDelete = null
|
||||
loadSessionMessages(sessionId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => newChat()}>
|
||||
<PlusIcon class="size-3.5" />
|
||||
New chat
|
||||
</Button>
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div class="flex flex-col gap-1 pr-2">
|
||||
{#each $sessions as session (session.id)}
|
||||
<div class="group relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col items-start gap-0.5 rounded-md border py-1.5 pl-2 pr-7 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
|
||||
onclick={() => handleClick(session.id)}
|
||||
>
|
||||
<span class="min-w-0 max-w-full truncate font-medium">{session.title || 'Untitled'}</span>
|
||||
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1 top-1.5 shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:bg-destructive/20 hover:text-destructive"
|
||||
onclick={(e) => handleDelete(e, session.id)}
|
||||
aria-label={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
||||
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
||||
>
|
||||
{#if confirmDelete === session.id}
|
||||
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
|
||||
{:else}
|
||||
<Trash2Icon class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-2 py-4 text-center text-xs text-muted-foreground">No sessions yet.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
30
web/src/lib/components/Spinner.svelte
Normal file
30
web/src/lib/components/Spinner.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
// A fading-blade spinner rather than a rotating arc — rotating a single
|
||||
// thin stroke via CSS transform reads as jittery at icon sizes (the arc's
|
||||
// sub-pixel edges shimmer each frame). Cycling opacity across fixed blades
|
||||
// avoids that entirely and is how native OS spinners do it.
|
||||
let { class: className = '' }: { class?: string } = $props()
|
||||
|
||||
const TICKS = 8
|
||||
const DUR = 0.9
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 24 24" class={className} fill="none" aria-hidden="true">
|
||||
{#each Array.from({ length: TICKS }) as _, i (i)}
|
||||
<rect
|
||||
x="11" y="1.5" width="2" height="6" rx="1"
|
||||
fill="currentColor"
|
||||
opacity="0.15"
|
||||
transform="rotate({i * (360 / TICKS)} 12 12)"
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="1;0.15"
|
||||
keyTimes="0;1"
|
||||
dur="{DUR}s"
|
||||
begin="{-(i * (DUR / TICKS)).toFixed(3)}s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
{/each}
|
||||
</svg>
|
||||
@@ -1,29 +1,259 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { startWorkspace } from '$lib/stores/workspace'
|
||||
import GoalHeader from './GoalHeader.svelte'
|
||||
import PlanProgress from './PlanProgress.svelte'
|
||||
import { startWorkspace, planSteps, currentTask, touched } from '$lib/stores/workspace'
|
||||
import { activityLog } from '$lib/stores/activity'
|
||||
import { streaming } from '$lib/stores/chat'
|
||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||
import SessionGraph from './SessionGraph.svelte'
|
||||
import SessionDigest from './SessionDigest.svelte'
|
||||
import ActivityTimeline from './ActivityTimeline.svelte'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
|
||||
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
|
||||
|
||||
onMount(() => startWorkspace())
|
||||
|
||||
let scopeOpen = $state(true)
|
||||
let planOpen = $state(true)
|
||||
let activityOpen = $state(true)
|
||||
|
||||
// Resize: distribute height across the 3 content areas.
|
||||
// Heights stored in px, minus header sizes. Default even split.
|
||||
let heights = $state([300, 200, 200])
|
||||
let resizing = $state(-1)
|
||||
let resizeStartY = $state(0)
|
||||
let resizeStartH = $state<[number, number]>([0, 0])
|
||||
|
||||
function onPointerDown(i: number, e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
resizing = i
|
||||
resizeStartY = e.clientY
|
||||
resizeStartH = [heights[i], heights[i + 1]]
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (resizing < 0) return
|
||||
const dy = e.clientY - resizeStartY
|
||||
const minH = 80
|
||||
// Clamp each section to minimum
|
||||
const newA = Math.max(minH, resizeStartH[0] + dy)
|
||||
const newB = Math.max(minH, resizeStartH[1] - dy)
|
||||
const newHeights = [...heights]
|
||||
newHeights[resizing] = newA
|
||||
newHeights[resizing + 1] = newB
|
||||
heights = newHeights
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
resizing = -1
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
// Plan collapsed status
|
||||
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
|
||||
const planTotal = $derived($planSteps.length)
|
||||
const planPct = $derived(planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0)
|
||||
|
||||
// When there are no plan steps, the empty state depends on WHY: a task that's
|
||||
// actively planning (or streaming its first turn) is genuinely waiting for one,
|
||||
// but a finished task that never planned (a read-only lookup, a direct answer)
|
||||
// will never get one — a perpetual "Awaiting plan…" there is misleading.
|
||||
const planPhase = $derived.by<'drafting' | 'none' | 'idle'>(() => {
|
||||
const st = $currentTask?.status
|
||||
if (st === 'done' || st === 'failed' || st === 'abandoned') return 'none'
|
||||
if (st === 'planning' || $streaming) return 'drafting'
|
||||
return 'idle'
|
||||
})
|
||||
|
||||
// Activity collapsed status
|
||||
const activityRunning = $derived($activityLog.filter((e) => e.status === 'running').length)
|
||||
const activityCount = $derived($activityLog.length)
|
||||
</script>
|
||||
|
||||
<!--
|
||||
The task's live control panel: goal + status, plan progress, a pinned
|
||||
question when the agent needs a decision, the live entity graph (pulses what
|
||||
the agent is touching, flags health changes), and the outcome/knowledge
|
||||
record once the task completes. Driven by the always-on events stream
|
||||
(see workspace.ts) so it keeps updating during server-side auto-continuation,
|
||||
not just while a chat turn is streaming.
|
||||
-->
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<GoalHeader />
|
||||
<PlanProgress />
|
||||
<OperatorQuestion />
|
||||
<div class="min-h-0 flex-1">
|
||||
|
||||
<!-- Scope -->
|
||||
<div class="flex shrink-0 flex-col border-b">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => (scopeOpen = !scopeOpen)}
|
||||
>
|
||||
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Scope</span>
|
||||
{#if !scopeOpen}
|
||||
<span class="ml-auto font-normal normal-case">{$touched.length ? `${$touched.length} entit${$touched.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if scopeOpen}
|
||||
<div style="height: {heights[0]}px">
|
||||
<SessionGraph />
|
||||
</div>
|
||||
<SessionDigest />
|
||||
<!-- resize handle -->
|
||||
<div
|
||||
class="h-1.5 cursor-row-resize border-b hover:bg-primary/30 touch-none"
|
||||
onpointerdown={(e) => onPointerDown(0, e)}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Plan -->
|
||||
<div class="flex shrink-0 flex-col border-b">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => (planOpen = !planOpen)}
|
||||
>
|
||||
{#if planOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Plan</span>
|
||||
{#if !planOpen}
|
||||
{#if planTotal > 0}
|
||||
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
|
||||
{:else if $currentTask?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$currentTask.goal}</span>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if planOpen}
|
||||
<div style="height: {heights[1]}px" class="flex flex-col overflow-y-auto">
|
||||
{#if $currentTask?.goal}
|
||||
<div class="flex items-start gap-2 px-3 py-2">
|
||||
<MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" />
|
||||
<span class="text-xs leading-snug text-foreground/90">{$currentTask.goal}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if planTotal > 0}
|
||||
<div class="px-3 pb-2.5">
|
||||
<div class="mb-1.5 flex items-baseline justify-between text-[11px]">
|
||||
<span class="font-medium text-foreground">{planDone} of {planTotal} done</span>
|
||||
<span class="tabular-nums text-muted-foreground">{planPct}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full bg-primary transition-all duration-500 ease-out" style="width: {planPct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="flex flex-col overflow-y-auto px-2 pb-2 text-[11px]">
|
||||
{#each $planSteps as step, i (step.id)}
|
||||
{@const isDone = step.status === 'done'}
|
||||
{@const isRunning = step.status === 'running'}
|
||||
<li class="relative flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors {isRunning ? 'bg-primary/5' : ''}">
|
||||
{#if i < $planSteps.length - 1}
|
||||
<span class="pointer-events-none absolute bottom-[-2px] left-[13.5px] top-[22px] w-px bg-border" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="relative z-10 mt-px flex size-3.5 shrink-0 items-center justify-center rounded-full bg-background">
|
||||
{#if isDone}
|
||||
<CircleCheckIcon class="size-3.5 text-primary" />
|
||||
{:else if isRunning}
|
||||
<Spinner class="size-3.5 text-primary" />
|
||||
{:else if step.status === 'failed'}
|
||||
<CircleXIcon class="size-3.5 text-destructive" />
|
||||
{:else if step.status === 'blocked'}
|
||||
<CirclePauseIcon class="size-3.5 text-warning" />
|
||||
{:else if step.status === 'skipped' || step.status === 'replaced'}
|
||||
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<CircleIcon class="size-3.5 text-muted-foreground/40" />
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="min-w-0 flex-1 leading-snug {isDone
|
||||
? 'text-muted-foreground line-through decoration-muted-foreground/40'
|
||||
: isRunning
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
>{step.title}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else if planPhase === 'drafting'}
|
||||
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
|
||||
<svg viewBox="0 0 140 88" class="h-16 w-auto text-primary" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<circle cx="16" cy="20" r="4.5" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<line x1="30" y1="20" x2="124" y2="20" opacity="0.4">
|
||||
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<circle cx="16" cy="44" r="4.5" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<line x1="30" y1="44" x2="102" y2="44" opacity="0.4">
|
||||
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<circle cx="16" cy="68" r="4.5" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<line x1="30" y1="68" x2="80" y2="68" opacity="0.4">
|
||||
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
|
||||
</line>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="text-xs text-muted-foreground">Drafting a plan…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
|
||||
<svg viewBox="0 0 140 88" class="h-16 w-auto text-muted-foreground/40" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<circle cx="16" cy="20" r="4.5" fill="currentColor" opacity="0.7" />
|
||||
<line x1="30" y1="20" x2="124" y2="20" opacity="0.35" />
|
||||
<circle cx="16" cy="44" r="4.5" fill="currentColor" opacity="0.45" />
|
||||
<line x1="30" y1="44" x2="102" y2="44" opacity="0.25" />
|
||||
<circle cx="16" cy="68" r="4.5" fill="none" opacity="0.3" />
|
||||
<line x1="30" y1="68" x2="80" y2="68" opacity="0.15" stroke-dasharray="2.5 3.5" />
|
||||
</g>
|
||||
</svg>
|
||||
<p class="max-w-[14rem] text-xs leading-relaxed text-muted-foreground">
|
||||
{planPhase === 'none' ? 'Handled directly — no plan needed' : 'No plan for this task yet'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- resize handle -->
|
||||
<div
|
||||
class="h-1.5 cursor-row-resize border-b hover:bg-primary/30 touch-none"
|
||||
onpointerdown={(e) => onPointerDown(1, e)}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Activity -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => (activityOpen = !activityOpen)}
|
||||
>
|
||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Event log</span>
|
||||
{#if !activityOpen}
|
||||
{#if $streaming && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if activityOpen}
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<ActivityTimeline />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import * as Collapsible from '$lib/components/ui/collapsible'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import Spinner from './Spinner.svelte'
|
||||
|
||||
let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
|
||||
|
||||
@@ -16,96 +15,73 @@
|
||||
const inlineCount = $derived(tools.length - bodyTools.length)
|
||||
|
||||
$effect(() => {
|
||||
if (active && !wasActive) {
|
||||
open = true
|
||||
}
|
||||
if (!active && wasActive) {
|
||||
open = false
|
||||
}
|
||||
if (active && !wasActive) open = true
|
||||
if (!active && wasActive) open = false
|
||||
wasActive = active
|
||||
})
|
||||
|
||||
const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
|
||||
const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
|
||||
const names = $derived(bodyTools.map((t) => t.name).join(', '))
|
||||
const total = $derived(bodyTools.length)
|
||||
|
||||
const runningTool = $derived(
|
||||
active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
|
||||
)
|
||||
|
||||
const ariaLabel = $derived(
|
||||
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 {
|
||||
if (!args || typeof args !== 'object') return ''
|
||||
return Object.entries(args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join(' ')
|
||||
.slice(0, 80)
|
||||
function toolLabel(t: ToolCallResult): string {
|
||||
if (!t.args || typeof t.args !== 'object') return t.name
|
||||
const firstArg = Object.values(t.args as Record<string, unknown>)[0]
|
||||
if (typeof firstArg === 'string' && firstArg.length < 50) return `${t.name} ${firstArg}`
|
||||
return t.name
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if bodyTools.length}
|
||||
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-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 < bodyTools.length}
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded border bg-card/50 text-xs">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-1.5 px-2 py-1 hover:bg-muted/30">
|
||||
<span class="shrink-0">
|
||||
{#if active && doneCount < total}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{:else if hasError}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<XIcon class="size-3 text-destructive" aria-hidden="true" />
|
||||
{:else}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<CheckIcon class="size-3 text-muted-foreground" aria-hidden="true" />
|
||||
{/if}
|
||||
|
||||
{#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}
|
||||
<span class="animate-pulse">…</span>
|
||||
</span>
|
||||
|
||||
<span class="text-muted-foreground">
|
||||
{#if active && doneCount < total}
|
||||
{doneCount}/{total}
|
||||
{:else}
|
||||
<span class="animate-pulse text-muted-foreground">working…</span>
|
||||
{total} tool{total === 1 ? '' : 's'}
|
||||
{/if}
|
||||
{:else}
|
||||
<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>
|
||||
<span> · {inlineCount} inline</span>
|
||||
{/if}
|
||||
<span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span>
|
||||
</span>
|
||||
|
||||
{#if active && runningTool}
|
||||
<span class="font-mono truncate">{toolLabel(runningTool)}<span class="animate-pulse">…</span></span>
|
||||
{/if}
|
||||
|
||||
<ChevronDownIcon
|
||||
class="size-3 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
|
||||
class="size-3 shrink-0 text-muted-foreground transition-transform duration-200 ml-auto {open ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-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" role="list" aria-label={ariaLabel}>
|
||||
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1">
|
||||
<div class="flex flex-col divide-y border-t px-2 py-1">
|
||||
{#each bodyTools as tool (tool.id)}
|
||||
<div class="p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5 py-0.5">
|
||||
{#if tool.type === 'tool_result' && tool.error}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||
{:else if tool.type === 'tool_result'}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||
{:else}
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<Spinner class="size-3 shrink-0 text-primary" />
|
||||
{/if}
|
||||
<span class="font-mono font-medium">{tool.name}</span>
|
||||
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
|
||||
</div>
|
||||
<div class="mt-1 max-h-48 overflow-y-auto rounded bg-background/60 p-2">
|
||||
{#if tool.args}
|
||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
|
||||
{/if}
|
||||
{#if tool.type === 'tool_result'}
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="font-mono text-[11px] truncate">{toolLabel(tool)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
|
||||
import { mode } from "mode-watcher";
|
||||
import { getTheme } from "$lib/stores/theme.svelte";
|
||||
import Loader2Icon from '@lucide/svelte/icons/loader-2';
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check';
|
||||
import OctagonXIcon from '@lucide/svelte/icons/octagon-x';
|
||||
@@ -11,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<Sonner
|
||||
theme={mode.current}
|
||||
theme={getTheme() === 'light' ? 'light' : 'dark'}
|
||||
class="toaster group"
|
||||
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
|
||||
{...restProps}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// or cross-origin (Wails webview, remote access). See
|
||||
// plans/2026-07-12-wails-desktop-app.md 0.2.
|
||||
|
||||
import { getToken, isOIDCConfigured } from './oidc'
|
||||
import { isOIDCConfigured, ensureToken, logout as oidcLogout } from './oidc'
|
||||
|
||||
export interface OikosConfig {
|
||||
apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin
|
||||
@@ -67,9 +67,18 @@ export function apiBase(path: string): string {
|
||||
}
|
||||
|
||||
// Resolves the auth token for a request: OIDC takes precedence, then static.
|
||||
function resolveAuthHeader(): string | null {
|
||||
const oidcToken = getToken()
|
||||
if (oidcToken) return `Bearer ${oidcToken}`
|
||||
// OIDC is async (may need to refresh an expired access_token); the static
|
||||
// fallback is synchronous. Returns the header value or null.
|
||||
async function resolveAuthHeader(): Promise<string | null> {
|
||||
// Try OIDC first. ensureToken() refreshes if the cached token is expired or
|
||||
// missing; if it returns a token we use it.
|
||||
if (isOIDCConfigured()) {
|
||||
const tok = await ensureToken()
|
||||
if (tok) return `Bearer ${tok}`
|
||||
// OIDC session exists but couldn't yield a usable token (e.g. expired
|
||||
// access_token with no refresh_token). Fall through to the static token
|
||||
// if one was configured — better than a blanket 401.
|
||||
}
|
||||
const c = getConfig()
|
||||
if (c.token) return `Bearer ${c.token}`
|
||||
return null
|
||||
@@ -79,27 +88,47 @@ function resolveAuthHeader(): string | null {
|
||||
// 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.
|
||||
//
|
||||
// OIDC tokens are short-lived; this wrapper awaits ensureToken() so an
|
||||
// expired access_token is refreshed before the request goes out, rather than
|
||||
// 401ing on the wire. On a 401 we flush the OIDC session once so the next
|
||||
// request can fall back to the static token (or re-prompt the operator).
|
||||
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()
|
||||
const authH = await resolveAuthHeader()
|
||||
if (authH) {
|
||||
headers['Authorization'] = authH
|
||||
}
|
||||
|
||||
return fetch(apiBase(path), { ...opts, headers })
|
||||
const res = await fetch(apiBase(path), { ...opts, headers })
|
||||
|
||||
// A 401 on a request we sent an Authorization header for means the token
|
||||
// the server just rejected is no longer valid. If OIDC is in use, clear it
|
||||
// so resolveAuthHeader() falls back to the static token next time (or the
|
||||
// operator gets re-prompted to log in). Don't loop: only one flush, and
|
||||
// only when we actually sent an Authorization header.
|
||||
if (res.status === 401 && authH && isOIDCConfigured()) {
|
||||
oidcLogout()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Authorization header, only for this route). Async so the OIDC access token
|
||||
// can be refreshed before the EventSource is constructed.
|
||||
export async function sseUrl(path: string): Promise<string> {
|
||||
const url = apiBase(path)
|
||||
const c = getConfig()
|
||||
const oidcToken = getToken()
|
||||
const token = oidcToken ?? c.token
|
||||
// Prefer a fresh OIDC token (refreshes if expired); fall back to the static token.
|
||||
let token: string | null = null
|
||||
if (isOIDCConfigured()) {
|
||||
token = await ensureToken()
|
||||
}
|
||||
if (!token) token = c.token ?? null
|
||||
if (!token) return url
|
||||
const sep = url.includes('?') ? '&' : '?'
|
||||
return `${url}${sep}token=${encodeURIComponent(token)}`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiBase } from './config'
|
||||
import { apiBase, getConfig } from './config'
|
||||
|
||||
interface OIDCConfig {
|
||||
issuer: string
|
||||
@@ -17,20 +17,27 @@ interface TokenResponse {
|
||||
interface OIDCState {
|
||||
config: OIDCConfig | null
|
||||
accessToken: string | null
|
||||
expiresAt: number | null // epoch ms when access_token expires, or null if unknown
|
||||
refreshToken: string | null
|
||||
user: string | null
|
||||
refreshing: Promise<string | null> | null
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'oidc_access_token'
|
||||
const EXPIRES_KEY = 'oidc_expires_at'
|
||||
const REFRESH_KEY = 'oidc_refresh_token'
|
||||
const USER_KEY = 'oidc_user'
|
||||
const PKCE_KEY = 'oidc_pkce_verifier'
|
||||
const STATE_KEY = 'oidc_state'
|
||||
|
||||
// Skew margin: treat a token as expired this many ms before its real exp,
|
||||
// so a refresh kicks in before a request races the wire and 401s.
|
||||
const EXPIRY_SKEW_MS = 30_000
|
||||
|
||||
let state: OIDCState = {
|
||||
config: null,
|
||||
accessToken: sessionStorage.getItem(SESSION_KEY),
|
||||
expiresAt: Number(sessionStorage.getItem(EXPIRES_KEY)) || null,
|
||||
refreshToken: localStorage.getItem(REFRESH_KEY),
|
||||
user: localStorage.getItem(USER_KEY),
|
||||
refreshing: null
|
||||
@@ -77,7 +84,19 @@ export async function startLogin(): Promise<void> {
|
||||
sessionStorage.setItem(PKCE_KEY, codeVerifier)
|
||||
sessionStorage.setItem(STATE_KEY, oidcState)
|
||||
|
||||
const redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
|
||||
const isDesktop = new URLSearchParams(location.search).has('desktop')
|
||||
|
||||
let redirectURI: string
|
||||
let stateParam: string
|
||||
|
||||
if (isDesktop) {
|
||||
const c = getConfig()
|
||||
redirectURI = (c.apiUrl || location.origin).replace(/\/$/, '') + '/oidc-callback'
|
||||
stateParam = oidcState + '.' + codeVerifier
|
||||
} else {
|
||||
redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
|
||||
stateParam = oidcState
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
@@ -85,10 +104,20 @@ export async function startLogin(): Promise<void> {
|
||||
redirect_uri: redirectURI,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state: oidcState,
|
||||
state: stateParam,
|
||||
scope: 'openid profile email'
|
||||
})
|
||||
|
||||
if (isDesktop) {
|
||||
const apiUrl = getConfig().apiUrl || ''
|
||||
const ret = encodeURIComponent(
|
||||
location.origin + location.pathname.replace(/\/$/, '') +
|
||||
'?desktop=1&apiUrl=' + encodeURIComponent(apiUrl)
|
||||
)
|
||||
location.href = `http://127.0.0.1:18901/oidc/start?apiUrl=${encodeURIComponent(apiUrl)}&ret=${ret}`
|
||||
return
|
||||
}
|
||||
|
||||
location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}`
|
||||
}
|
||||
|
||||
@@ -150,7 +179,15 @@ function parseIDTokenUser(idToken: string): string | null {
|
||||
|
||||
function storeTokens(tokens: TokenResponse) {
|
||||
state.accessToken = tokens.access_token
|
||||
|
||||
// Track expiry so getToken()/ensureToken() can refresh proactively. Default
|
||||
// to 5 min if the provider omits expires_in — a safe lower bound that keeps
|
||||
// refresh on a sane cadence rather than treating the token as never-expiring.
|
||||
const ttl = tokens.expires_in ?? 300
|
||||
state.expiresAt = Date.now() + ttl * 1000
|
||||
|
||||
sessionStorage.setItem(SESSION_KEY, tokens.access_token)
|
||||
sessionStorage.setItem(EXPIRES_KEY, String(state.expiresAt))
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
state.refreshToken = tokens.refresh_token
|
||||
@@ -159,6 +196,12 @@ function storeTokens(tokens: TokenResponse) {
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
// Treat a token whose exp we never recorded (e.g. a pre-fix login) as
|
||||
// usable once: if it's still valid server-side it'll pass; if not, the
|
||||
// 401 handler in fetchWithAuth will flush it and trigger refresh.
|
||||
if (state.expiresAt !== null && Date.now() >= state.expiresAt - EXPIRY_SKEW_MS) {
|
||||
return null
|
||||
}
|
||||
return state.accessToken
|
||||
}
|
||||
|
||||
@@ -171,7 +214,10 @@ export function isOIDCAvailable(): boolean {
|
||||
}
|
||||
|
||||
export async function ensureToken(): Promise<string | null> {
|
||||
if (state.accessToken) return state.accessToken
|
||||
// getToken() returns null when the token is missing OR expired-but-present.
|
||||
// Both cases should trigger a refresh if we have a refresh_token.
|
||||
const tok = getToken()
|
||||
if (tok) return tok
|
||||
|
||||
if (state.refreshToken) {
|
||||
return refreshAccessToken()
|
||||
@@ -223,9 +269,11 @@ async function refreshAccessToken(): Promise<string | null> {
|
||||
|
||||
function clearTokens() {
|
||||
state.accessToken = null
|
||||
state.expiresAt = null
|
||||
state.refreshToken = null
|
||||
state.user = null
|
||||
sessionStorage.removeItem(SESSION_KEY)
|
||||
sessionStorage.removeItem(EXPIRES_KEY)
|
||||
localStorage.removeItem(REFRESH_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
@@ -235,14 +283,19 @@ export function logout(): void {
|
||||
}
|
||||
|
||||
export function isOIDCConfigured(): boolean {
|
||||
return !!(state.accessToken || state.refreshToken)
|
||||
// A session counts as configured if there's a refresh_token (can recover an
|
||||
// expired access_token) or a still-valid access_token. An expired access
|
||||
// token with no refresh_token means we'd have to re-login, so don't claim
|
||||
// OIDC is configured in that state — let the static token (if any) take over.
|
||||
if (state.refreshToken) return true
|
||||
return getToken() !== null
|
||||
}
|
||||
|
||||
export async function initOIDC(): Promise<boolean> {
|
||||
if (state.accessToken) return true
|
||||
|
||||
if (state.refreshToken) {
|
||||
const token = await refreshAccessToken()
|
||||
// Use ensureToken so an expired-but-present access_token (e.g. page reload
|
||||
// mid-session) triggers a refresh instead of being returned as-is.
|
||||
if (state.refreshToken || state.accessToken) {
|
||||
const token = await ensureToken()
|
||||
return token !== null
|
||||
}
|
||||
|
||||
|
||||
108
web/src/lib/renderers/ExecutionStatus.svelte
Normal file
108
web/src/lib/renderers/ExecutionStatus.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import { getExecution } from '$lib/api'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ClockIcon from '@lucide/svelte/icons/clock'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
|
||||
const executions = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = Array.isArray(tool.result) ? tool.result : (tool.result as any)?.data
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
completed: 'var(--success)',
|
||||
failed: 'var(--destructive)',
|
||||
cancelled: 'var(--destructive)',
|
||||
denied: 'var(--destructive)',
|
||||
revoked: 'var(--destructive)',
|
||||
running: 'var(--warning)',
|
||||
approved: 'var(--warning)',
|
||||
pending_approval: 'var(--muted-foreground)',
|
||||
queued: 'var(--muted-foreground)',
|
||||
}
|
||||
|
||||
function statusLabel(s: string): string {
|
||||
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function fmtDuration(ms?: number): string {
|
||||
if (!ms) return ''
|
||||
const s = Math.round(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
if (!s) return ''
|
||||
return s.length > n ? s.slice(0, n) + '…' : s
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Execution status</span>
|
||||
<span class="animate-pulse text-muted-foreground">checking…</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Execution status</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if executions && executions.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs">
|
||||
<div class="flex flex-col divide-y">
|
||||
{#each executions as exec (exec.execution_id ?? exec.id)}
|
||||
{@const status = exec.status ?? 'unknown'}
|
||||
{@const color = statusColors[status] ?? 'var(--muted-foreground)'}
|
||||
{@const isRunning = status === 'running' || status === 'approved'}
|
||||
<div class="flex items-center gap-2 px-3 py-2">
|
||||
{#if status === 'completed'}
|
||||
<CheckIcon class="size-3 shrink-0" style="color: {color}" aria-hidden="true" />
|
||||
{:else if status === 'failed' || status === 'cancelled' || status === 'denied' || status === 'revoked'}
|
||||
<XIcon class="size-3 shrink-0" style="color: {color}" aria-hidden="true" />
|
||||
{:else if isRunning}
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin" style="color: {color}" aria-hidden="true" />
|
||||
{:else}
|
||||
<ClockIcon class="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="font-mono font-medium">{truncate(exec.execution_id ?? exec.id ?? '', 12)}</span>
|
||||
<span class="text-muted-foreground">{statusLabel(status)}</span>
|
||||
{#if exec.action}
|
||||
<span class="text-muted-foreground">· {truncate(exec.action, 40)}</span>
|
||||
{/if}
|
||||
{#if exec.duration_ms}
|
||||
<span class="text-muted-foreground">· {fmtDuration(exec.duration_ms)}</span>
|
||||
{/if}
|
||||
<span class="ml-auto inline-block rounded px-1.5 py-0.5 font-medium text-[10px]" style="background: {color}22; color: {color}">
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
</div>
|
||||
{#if exec.result || exec.error}
|
||||
<div class="max-h-32 overflow-y-auto bg-background/60 px-3 py-1.5">
|
||||
{#if exec.error}
|
||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-destructive">{exec.error}</pre>
|
||||
{:else if exec.result}
|
||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{typeof exec.result === 'string' ? exec.result : JSON.stringify(exec.result, null, 2)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Execution status</span>
|
||||
<span class="text-muted-foreground">no active executions</span>
|
||||
</div>
|
||||
{/if}
|
||||
9
web/src/lib/renderers/execution-status.ts
Normal file
9
web/src/lib/renderers/execution-status.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import ExecutionStatus from './ExecutionStatus.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) => t.name === 'get_execution_status',
|
||||
component: ExecutionStatus,
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { init as initBlastRadius } from './blast-radius'
|
||||
import { init as initChangeLog } from './change-log'
|
||||
import { init as initFleetSnapshot } from './fleet-snapshot'
|
||||
import { init as initMetricChart } from './metric-chart'
|
||||
import { init as initExecutionStatus } from './execution-status'
|
||||
|
||||
initEntityCard()
|
||||
initHealthSummary()
|
||||
@@ -17,3 +18,4 @@ initBlastRadius()
|
||||
initChangeLog()
|
||||
initFleetSnapshot()
|
||||
initMetricChart()
|
||||
initExecutionStatus()
|
||||
|
||||
203
web/src/lib/stores/activity.ts
Normal file
203
web/src/lib/stores/activity.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { derived } from 'svelte/store'
|
||||
import { messages, type ToolCallResult } from './chat'
|
||||
import { planSteps, currentTask } from './workspace'
|
||||
|
||||
export { type ToolCallResult }
|
||||
|
||||
export interface ActivityEntry {
|
||||
id: string
|
||||
type: 'goal' | 'plan' | 'step_running' | 'step_done' | 'step_failed' |
|
||||
'tool_running' | 'tool_done' | 'tool_error' |
|
||||
'knowledge' | 'complete' | 'question' | 'error'
|
||||
description: string
|
||||
detail?: string
|
||||
args?: string
|
||||
timestamp: number
|
||||
toolName?: string
|
||||
stepSeq?: number
|
||||
indent?: boolean
|
||||
status: 'running' | 'done' | 'failed'
|
||||
}
|
||||
|
||||
// Detail text is kept full-length (not hard-truncated to a preview snippet)
|
||||
// so the expanded view has something worth pretty-printing — capped only as
|
||||
// a safety net against pathological payloads (a full fleet dump, etc).
|
||||
const DETAIL_MAX = 8000
|
||||
|
||||
function summarizeArgs(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined
|
||||
if (Object.keys(args).length === 0) return undefined
|
||||
try {
|
||||
return JSON.stringify(args)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyResult(result: unknown): string {
|
||||
const s = typeof result === 'string' ? result : JSON.stringify(result ?? '')
|
||||
return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s
|
||||
}
|
||||
|
||||
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) => {
|
||||
const entries: ActivityEntry[] = []
|
||||
const now = Date.now()
|
||||
|
||||
// Goal
|
||||
if ($task?.goal) {
|
||||
entries.push({ id: 'goal', type: 'goal', description: $task.goal, timestamp: 0, status: 'done' })
|
||||
}
|
||||
|
||||
// Plan steps
|
||||
for (const s of $steps) {
|
||||
if (s.status === 'pending') continue
|
||||
const stepLabel = s.title || `Step ${s.seq}`
|
||||
entries.push({
|
||||
id: s.id,
|
||||
type: s.status === 'running' ? 'step_running' : s.status === 'done' ? 'step_done' : 'step_failed',
|
||||
description: `Step ${s.seq}: ${stepLabel}`,
|
||||
detail: s.detail || undefined,
|
||||
timestamp: s.started_at ? new Date(s.started_at).getTime() : now,
|
||||
status: s.status === 'running' ? 'running' : s.status === 'done' ? 'done' : 'failed'
|
||||
})
|
||||
}
|
||||
|
||||
// Tool calls (from messages). Tag each tool with the plan step that's
|
||||
// currently running when it fires.
|
||||
let currentStepSeq = 0
|
||||
let entryIdx = 0
|
||||
for (let mi = 0; mi < $msgs.length; mi++) {
|
||||
for (const t of $msgs[mi].tools) {
|
||||
// Track current step from update_plan_step calls
|
||||
if (t.type === 'tool_use' && t.name === 'update_plan_step') {
|
||||
const s = (t.args as any)?.seq as number | undefined
|
||||
const status = (t.args as any)?.status as string | undefined
|
||||
if (s && status === 'running') currentStepSeq = s
|
||||
} else if (t.name === 'set_goal' || t.name === 'propose_plan' || t.name === 'complete_task') {
|
||||
currentStepSeq = 0
|
||||
}
|
||||
|
||||
const label = toolActivityLabel(t)
|
||||
const stepTag = currentStepSeq > 0 ? currentStepSeq : undefined
|
||||
if (t.type === 'tool_use') {
|
||||
entries.push({
|
||||
id: t.id ?? `tool_${mi}_${entryIdx++}`,
|
||||
type: 'tool_running',
|
||||
description: label,
|
||||
args: summarizeArgs(t.args),
|
||||
timestamp: now - ($msgs.length - mi) * 1000,
|
||||
toolName: t.name,
|
||||
stepSeq: stepTag,
|
||||
indent: stepTag != null,
|
||||
status: 'running'
|
||||
})
|
||||
} else if (t.type === 'tool_result') {
|
||||
const running = entries.find((e) =>
|
||||
e.type === 'tool_running' && e.id === t.id && e.status === 'running'
|
||||
)
|
||||
if (running && t.error) {
|
||||
running.type = 'tool_error'
|
||||
running.status = 'failed'
|
||||
running.description = `${label}: ${t.error.slice(0, 80)}`
|
||||
running.detail = t.error
|
||||
} else if (running) {
|
||||
running.type = 'tool_done'
|
||||
running.status = 'done'
|
||||
running.detail = stringifyResult(t.result)
|
||||
} else {
|
||||
// Historical/persisted tool calls arrive as one merged record (args
|
||||
// + result on the same object, see mergeToolCalls in chat.ts) rather
|
||||
// than a separate tool_use/tool_result pair — there's never a
|
||||
// "running" entry to attach to, so this branch has to build the
|
||||
// full entry itself. It used to fall back to the raw tool name
|
||||
// (e.g. "get_entity") instead of the humanized label here.
|
||||
entries.push({
|
||||
id: t.id ?? `tool_${mi}_${entryIdx++}`,
|
||||
type: t.error ? 'tool_error' : 'tool_done',
|
||||
description: t.error ? `${label}: ${t.error.slice(0, 80)}` : label,
|
||||
detail: t.error ? t.error : stringifyResult(t.result),
|
||||
args: summarizeArgs(t.args),
|
||||
timestamp: now - ($msgs.length - mi) * 1000,
|
||||
toolName: t.name,
|
||||
stepSeq: stepTag,
|
||||
indent: stepTag != null,
|
||||
status: t.error ? 'failed' : 'done'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Knowledge recorded — detect from upsert_knowledge tool results
|
||||
for (let mi = 0; mi < $msgs.length; mi++) {
|
||||
for (const t of $msgs[mi].tools) {
|
||||
if (t.type === 'tool_result' && t.name === 'upsert_knowledge' && !t.error) {
|
||||
const title = t.args?.title ?? ''
|
||||
entries.push({
|
||||
id: `knowledge_${mi}`,
|
||||
type: 'knowledge',
|
||||
description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge',
|
||||
timestamp: now - ($msgs.length - mi) * 1000,
|
||||
status: 'done'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Task completion
|
||||
if ($task?.outcome) {
|
||||
entries.push({
|
||||
id: 'complete',
|
||||
type: 'complete',
|
||||
description: $task.summary || `Task ${$task.outcome}`,
|
||||
timestamp: now,
|
||||
status: $task.outcome === 'failure' ? 'failed' : 'done'
|
||||
})
|
||||
}
|
||||
|
||||
// Note: approval entries were removed from activityLog (2026-07-15).
|
||||
// They were always `status: 'running'` and never transitioned to 'done'
|
||||
// (the derived store builds from tool-call text, not execution status),
|
||||
// which caused the AgentIndicator to latch onto a stale "Approval: ..."
|
||||
// entry and never clear — even after the session completed. Approvals
|
||||
// are tracked via the REST /approvals endpoint (context.ts, Ops.svelte)
|
||||
// and rendered as InlineApproval cards in the chat (or Ops page), not
|
||||
// in the activity log.
|
||||
|
||||
// Sort oldest first
|
||||
entries.sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
return entries
|
||||
})
|
||||
|
||||
function toolActivityLabel(t: ToolCallResult): string {
|
||||
const args = t.args ?? {}
|
||||
switch (t.name) {
|
||||
case 'set_goal': return 'Set goal'
|
||||
case 'propose_plan': return 'Proposed plan'
|
||||
case 'search_knowledge': return `Research: ${args.query || ''}`
|
||||
case 'get_entity': return `Lookup: ${args.slug_or_id || ''}`
|
||||
case 'get_entity_knowledge': return 'Check prior knowledge'
|
||||
case 'get_relations': return 'Check relationships'
|
||||
case 'list_lxcs': return 'List containers'
|
||||
case 'list_entities': return 'List entities'
|
||||
case 'get_health_summary': return 'Fleet health'
|
||||
case 'get_state_snapshot': return 'State snapshot'
|
||||
case 'run': {
|
||||
const purpose = args.purpose as string || ''
|
||||
const target = (args.target as string) || ''
|
||||
if (purpose) return purpose
|
||||
if (target) return `Run on ${target}`
|
||||
return 'Run command'
|
||||
}
|
||||
case 'get_execution_status': return 'Check execution'
|
||||
case 'update_plan_step': return 'Update plan'
|
||||
case 'upsert_knowledge': return 'Record knowledge'
|
||||
case 'complete_task': return 'Complete task'
|
||||
case 'ping_service': return 'Check service'
|
||||
case 'ask_operator': return 'Ask operator'
|
||||
// Unmapped tool (new/uncommon) — humanize the raw name rather than
|
||||
// showing it verbatim, e.g. "revoke_execution" -> "Revoke execution".
|
||||
default: return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ function extractApprovals(tools: ToolCallResult[]): PendingApproval[] {
|
||||
if (m) {
|
||||
out.push({
|
||||
executionId: m[1],
|
||||
action: t.args?.action ?? t.args?.purpose ?? t.name ?? 'unknown',
|
||||
action: t.args?.purpose?.slice(0, 60) ?? t.args?.action ?? t.name ?? 'unknown',
|
||||
target: t.args?.target ?? 'unknown',
|
||||
destructive: /\bDESTRUCTIVE\b/.test(text),
|
||||
command: t.args?.command,
|
||||
@@ -66,10 +66,21 @@ function mid(): string {
|
||||
|
||||
export const messages = writable<ChatMessage[]>([])
|
||||
export const streaming = writable(false)
|
||||
export const connectionState = writable<'connected' | 'disconnected' | 'reconnecting'>('connected')
|
||||
export const currentSession = writable<string | null>(null)
|
||||
export const sessions = writable<Session[]>([])
|
||||
export const sessionMessages = writable<Message[]>([])
|
||||
export const error = writable<string | null>(null)
|
||||
export const chatErrors = writable<{ id: string; message: string; action?: string }[]>([])
|
||||
|
||||
export function dismissError(id: string) {
|
||||
chatErrors.update((e) => e.filter((x) => x.id !== id))
|
||||
}
|
||||
|
||||
export function addChatError(message: string, action?: string) {
|
||||
chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action }])
|
||||
}
|
||||
|
||||
|
||||
// Per-session controller tracking. Multiple tasks can stream concurrently
|
||||
// (see sendMessage's session guard above this used to be a single global
|
||||
@@ -153,7 +164,9 @@ function startPolling(sessionId: string) {
|
||||
stopPolling()
|
||||
pollingSessionId = sessionId
|
||||
pollTimer = setInterval(async () => {
|
||||
if (get(streaming)) return
|
||||
// Allow polling while disconnected — the agent is still working
|
||||
// server-side and the poller is the only way to see it.
|
||||
if (get(streaming) && get(connectionState) === 'connected') return
|
||||
if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return
|
||||
const msgs = await fetchMessages(sessionId)
|
||||
if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time
|
||||
@@ -217,6 +230,7 @@ export function sendMessage(text: string) {
|
||||
// auto-continuation.
|
||||
const openedFor = get(currentSession)
|
||||
let streamSessionID = openedFor
|
||||
let receivedDone = false
|
||||
|
||||
const controller = streamChat(
|
||||
text,
|
||||
@@ -271,6 +285,7 @@ export function sendMessage(text: string) {
|
||||
last.tools = last.tools.map((t) =>
|
||||
t.id === ev.data.id ? updated : t
|
||||
)
|
||||
last.pendingApprovals = extractApprovals(last.tools)
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
@@ -293,6 +308,8 @@ export function sendMessage(text: string) {
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'done') {
|
||||
receivedDone = true
|
||||
connectionState.set('connected')
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
@@ -314,14 +331,29 @@ export function sendMessage(text: string) {
|
||||
}
|
||||
},
|
||||
(err: string) => {
|
||||
if (get(currentSession) === streamSessionID) error.set(err)
|
||||
// Distinguish user abort from network drop.
|
||||
if (err === 'AbortError' || err.includes('aborted')) {
|
||||
if (get(currentSession) === streamSessionID) streaming.set(false)
|
||||
return
|
||||
}
|
||||
// Network blip / server restart — initiate reconnect.
|
||||
if (get(currentSession) === streamSessionID) {
|
||||
error.set(err)
|
||||
if (!receivedDone && streamSessionID) {
|
||||
handleDisconnect(streamSessionID)
|
||||
} else {
|
||||
streaming.set(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (get(currentSession) === streamSessionID) streaming.set(false)
|
||||
// Clean up whichever slot this controller ended up in — normally
|
||||
// activeControllers[streamSessionID] once the 'session' event has
|
||||
// fired, but fall back to pendingController for the (rare) case where
|
||||
// the stream errored/completed before ever getting one.
|
||||
// SSE stream completed without error. If we never received 'done',
|
||||
// the connection was severed mid-turn — treat as disconnect.
|
||||
if (!receivedDone && streamSessionID && get(currentSession) === streamSessionID) {
|
||||
handleDisconnect(streamSessionID)
|
||||
} else if (get(currentSession) === streamSessionID) {
|
||||
streaming.set(false)
|
||||
}
|
||||
if (streamSessionID && activeControllers.get(streamSessionID) === controller) {
|
||||
activeControllers.delete(streamSessionID)
|
||||
}
|
||||
@@ -340,12 +372,87 @@ export function sendMessage(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleDisconnect is called when the SSE stream drops mid-turn without
|
||||
// receiving a 'done' event. Falls back to polling and attempts reconnection.
|
||||
function handleDisconnect(sessionId: string) {
|
||||
const MAX_RECONNECT = 3
|
||||
connectionState.set('disconnected')
|
||||
startPolling(sessionId)
|
||||
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
|
||||
|
||||
let attempts = 0
|
||||
let delay = 1000
|
||||
|
||||
const attemptReconnect = () => {
|
||||
if (get(currentSession) !== sessionId || attempts >= MAX_RECONNECT) {
|
||||
connectionState.set('disconnected')
|
||||
streaming.set(false)
|
||||
return
|
||||
}
|
||||
if (attempts > 0) {
|
||||
connectionState.set('reconnecting')
|
||||
addChatError(`Reconnecting to agent (attempt ${attempts + 1}/${MAX_RECONNECT})…`, 'Dismiss')
|
||||
}
|
||||
attempts++
|
||||
const controller = streamChat(
|
||||
'',
|
||||
sessionId,
|
||||
(_ev: ChatEvent) => {},
|
||||
(_err: string) => {
|
||||
delay = Math.min(delay * 2, 8000)
|
||||
setTimeout(attemptReconnect, delay)
|
||||
},
|
||||
() => {
|
||||
if (get(currentSession) === sessionId) {
|
||||
connectionState.set('connected')
|
||||
streaming.set(false)
|
||||
loadSessionMessages(sessionId)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (activeControllers.get(sessionId)) {
|
||||
activeControllers.get(sessionId)?.abort()
|
||||
}
|
||||
activeControllers.set(sessionId, controller)
|
||||
}
|
||||
|
||||
setTimeout(attemptReconnect, delay)
|
||||
}
|
||||
|
||||
export function reconnect() {
|
||||
const sid = get(currentSession)
|
||||
if (!sid) return
|
||||
connectionState.set('reconnecting')
|
||||
const controller = streamChat(
|
||||
'',
|
||||
sid,
|
||||
(_ev: ChatEvent) => {},
|
||||
(_err: string) => {
|
||||
connectionState.set('disconnected')
|
||||
addChatError('Reconnect failed. The task may still be running — try sending a message to wake the agent.', 'Dismiss')
|
||||
},
|
||||
() => {
|
||||
if (get(currentSession) === sid) {
|
||||
connectionState.set('connected')
|
||||
streaming.set(false)
|
||||
loadSessionMessages(sid)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (activeControllers.get(sid)) {
|
||||
activeControllers.get(sid)?.abort()
|
||||
}
|
||||
activeControllers.set(sid, controller)
|
||||
}
|
||||
|
||||
export function newChat() {
|
||||
cancelStream()
|
||||
stopPolling()
|
||||
connectionState.set('connected')
|
||||
currentSession.set(null)
|
||||
messages.set([])
|
||||
error.set(null)
|
||||
chatErrors.set([])
|
||||
streaming.set(false) // fresh view — see loadSessionMessages for why this must not depend on cancelStream's own reset
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,12 @@ export const connectionState = writable<'connecting' | 'open' | 'closed'>('conne
|
||||
let source: EventSource | null = null
|
||||
let subscriberCount = 0
|
||||
|
||||
function connect() {
|
||||
async function connect() {
|
||||
if (source) return
|
||||
connectionState.set('connecting')
|
||||
// The browser's EventSource sends Last-Event-ID automatically on reconnect.
|
||||
source = new EventSource(sseUrl('/api/v1/events/stream'))
|
||||
// The browser's EventSource sends Last-event-ID automatically on reconnect.
|
||||
// sseUrl is async so the OIDC access token is refreshed if expired.
|
||||
source = new EventSource(await sseUrl('/api/v1/events/stream'))
|
||||
|
||||
source.onopen = () => connectionState.set('open')
|
||||
|
||||
|
||||
46
web/src/lib/stores/theme.svelte.ts
Normal file
46
web/src/lib/stores/theme.svelte.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
const STORAGE_KEY = 'oikos-theme'
|
||||
|
||||
function applyClass(theme: Theme): void {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function storedTheme(): Theme {
|
||||
if (typeof localStorage === 'undefined') return 'dark'
|
||||
const v = localStorage.getItem(STORAGE_KEY)
|
||||
if (v === 'light' || v === 'dark') return v
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
let current: Theme = $state(storedTheme())
|
||||
|
||||
applyClass(current)
|
||||
|
||||
export function setTheme(t: Theme): void {
|
||||
current = t
|
||||
applyClass(t)
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, t)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTheme(): Theme {
|
||||
return current
|
||||
}
|
||||
|
||||
export function toggleTheme(): Theme {
|
||||
const next = current === 'dark' ? 'light' : 'dark'
|
||||
setTheme(next)
|
||||
return next
|
||||
}
|
||||
|
||||
export const THEME_LABELS: Record<Theme, string> = {
|
||||
light: 'Terracotta',
|
||||
dark: 'Carbon'
|
||||
}
|
||||
1
web/src/lib/version.ts
Normal file
1
web/src/lib/version.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const VERSION: string = __OIKOS_VERSION__
|
||||
@@ -1,11 +1,30 @@
|
||||
import { mount } from 'svelte'
|
||||
import App from './App.svelte'
|
||||
import './app.css'
|
||||
import { initConfig } from '$lib/config'
|
||||
import { initConfig, setConfig, getConfig } from '$lib/config'
|
||||
|
||||
function handleDesktopToken() {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const token = params.get('token')
|
||||
if (token && new URLSearchParams(location.search).has('desktop')) {
|
||||
const apiUrl = params.get('apiUrl') || getConfig().apiUrl || ''
|
||||
setConfig({ apiUrl, token, isDesktop: true })
|
||||
initConfig({ apiUrl, token, isDesktop: true })
|
||||
params.delete('token')
|
||||
const q = params.toString()
|
||||
history.replaceState(null, '', location.pathname + (q ? '?' + q : ''))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function start() {
|
||||
initConfig()
|
||||
handleDesktopToken()
|
||||
|
||||
requestAnimationFrame(() => import('./lib/renderers'))
|
||||
|
||||
const app = mount(App, { target: document.getElementById('app')! })
|
||||
export default app
|
||||
mount(App, { target: document.getElementById('app')! })
|
||||
}
|
||||
|
||||
start()
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
|
||||
import { activityLog } from '$lib/stores/activity'
|
||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
||||
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
||||
import { getToolRenderer } from '$lib/tool-renderers'
|
||||
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||||
import SquareIcon from '@lucide/svelte/icons/square'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
@@ -16,18 +15,30 @@
|
||||
|
||||
let input = $state('')
|
||||
let messagesEnd = $state<HTMLDivElement | null>(null)
|
||||
let scrolledUp = $state(false)
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
|
||||
const MAX_INLINE_CARDS = 5
|
||||
|
||||
function getInlineTools(msg: { tools: any[] }): any[] {
|
||||
const matched = msg.tools.filter((t) => getToolRenderer(t))
|
||||
if (matched.length <= MAX_INLINE_CARDS) return matched
|
||||
return matched.slice(0, MAX_INLINE_CARDS)
|
||||
function isNearBottom(): boolean {
|
||||
if (!container) return true
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
return scrollHeight - scrollTop - clientHeight < 80
|
||||
}
|
||||
|
||||
function getRemaining(msg: { tools: any[] }, inline: any[]): any[] {
|
||||
const inlineIds = new Set(inline.map((t) => t.id))
|
||||
return msg.tools.filter((t) => !inlineIds.has(t.id))
|
||||
function onScroll() {
|
||||
scrolledUp = !isNearBottom()
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
|
||||
$effect(() => {
|
||||
void $messages
|
||||
if ($streaming || !scrolledUp) {
|
||||
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||||
}
|
||||
})
|
||||
|
||||
// Reset scroll lock when user sends a message.
|
||||
function submitFollows() {
|
||||
scrolledUp = false
|
||||
}
|
||||
|
||||
// Resizable right rail (session graph). Persisted so it survives reloads.
|
||||
@@ -59,12 +70,6 @@
|
||||
window.addEventListener('pointerup', up)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void $messages
|
||||
void $streaming
|
||||
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||||
})
|
||||
|
||||
function render(text: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
|
||||
}
|
||||
@@ -73,6 +78,7 @@
|
||||
const text = input.trim()
|
||||
if (!text || $streaming) return
|
||||
input = ''
|
||||
scrolledUp = false
|
||||
sendMessage(text)
|
||||
}
|
||||
|
||||
@@ -97,13 +103,8 @@
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
{#if showRail}
|
||||
<div class="hidden md:block">
|
||||
<SessionRail />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||
{#if $messages.length === 0}
|
||||
<div class="flex flex-col items-center gap-6 pt-24 text-center">
|
||||
@@ -124,43 +125,45 @@
|
||||
{#each $messages as msg, i (msg.id)}
|
||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
{#if msg.role === 'user'}
|
||||
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
|
||||
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
{#each getInlineTools(msg) as tool (tool.id)}
|
||||
{@const renderer = getToolRenderer(tool)}
|
||||
{#if renderer}
|
||||
<renderer.component {tool} />
|
||||
{/if}
|
||||
{/each}
|
||||
<ToolCallGroup
|
||||
tools={msg.tools}
|
||||
unmatched={getRemaining(msg, getInlineTools(msg))}
|
||||
active={$streaming && i === $messages.length - 1}
|
||||
/>
|
||||
{#if msg.text}
|
||||
<div class="prose-chat max-w-none text-sm leading-relaxed">
|
||||
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
{@html render(msg.text)}
|
||||
</div>
|
||||
{:else if msg.tools.length === 0}
|
||||
<div class="flex items-center gap-1.5 py-1 text-sm text-muted-foreground">
|
||||
<span class="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]"></span>
|
||||
<span class="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]"></span>
|
||||
<span class="size-1.5 animate-bounce rounded-full bg-current"></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.pendingApprovals.length > 0}
|
||||
<InlineApproval approvals={msg.pendingApprovals} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<AgentIndicator
|
||||
active={$streaming || $activityLog.some((e) => e.status === 'running')}
|
||||
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
|
||||
error={$error}
|
||||
/>
|
||||
<div bind:this={messagesEnd}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $connectionState === 'disconnected'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs">
|
||||
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<span class="text-warning-foreground flex-1">Agent connection lost. The task may still be running.</span>
|
||||
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={reconnect}>Reconnect</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if $connectionState === 'reconnecting'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
|
||||
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $error}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
@@ -169,7 +172,19 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-t bg-card/50 p-3">
|
||||
{#each $chatErrors as err (err.id)}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<span class="flex-1">{err.message}</span>
|
||||
{#if err.action}
|
||||
<Button size="xs" variant="ghost" class="h-6 text-[11px]" onclick={() => dismissError(err.id)}>{err.action}</Button>
|
||||
{/if}
|
||||
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => dismissError(err.id)} aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="border-t bg-card/50 p-3 input-ornament relative">
|
||||
<form
|
||||
class="mx-auto flex max-w-3xl items-end gap-2"
|
||||
onsubmit={(e) => {
|
||||
@@ -220,63 +235,176 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Minimal markdown styling for assistant messages. */
|
||||
/* ── Art Nouveau chat styling ── */
|
||||
|
||||
/* Assistant message wrapper */
|
||||
.assistant-msg {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* User message — soft terracotta bubble, gentle lift */
|
||||
.user-msg {
|
||||
box-shadow: 0 1px 8px -4px var(--primary);
|
||||
}
|
||||
|
||||
/* Prose overrides */
|
||||
.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(ul) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.prose-chat :global(ol) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.prose-chat :global(li) {
|
||||
margin-bottom: 0.125rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
.prose-chat :global(li::marker) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.prose-chat :global(code) {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
padding: 0.15em 0.4em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.prose-chat :global(pre) {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.625rem 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
.prose-chat :global(pre)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
||||
opacity: 0.4;
|
||||
}
|
||||
.prose-chat :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
||||
margin separates sections; the first heading in a message doesn't. */
|
||||
.prose-chat :global(h1),
|
||||
.prose-chat :global(h2),
|
||||
.prose-chat :global(h3) {
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0 0.375rem;
|
||||
font-size: 1em;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
font-size: 1.03em;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.prose-chat :global(:first-child):is(h1, h2, h3) {
|
||||
margin-top: 0;
|
||||
}
|
||||
.prose-chat :global(h1)::after,
|
||||
.prose-chat :global(h2)::after,
|
||||
.prose-chat :global(h3)::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 2.5rem;
|
||||
height: 2px;
|
||||
margin-top: 4px;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(to right, var(--primary), transparent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.prose-chat :global(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(th) {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose-chat :global(th),
|
||||
.prose-chat :global(td) {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose-chat :global(blockquote) {
|
||||
border-left: 3px solid var(--border);
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 0.5rem;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
}
|
||||
.prose-chat :global(blockquote)::before {
|
||||
content: '“';
|
||||
position: absolute;
|
||||
left: -0.15rem;
|
||||
top: -0.35rem;
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary);
|
||||
opacity: 0.6;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.prose-chat :global(hr) {
|
||||
border: none;
|
||||
height: 1px;
|
||||
margin: 0.75rem 0;
|
||||
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
|
||||
}
|
||||
|
||||
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
|
||||
terracotta accent stay meaningful (code, headings, links). */
|
||||
.prose-chat :global(strong) {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-chat :global(a) {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Input area ornament */
|
||||
.input-ornament::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 2rem;
|
||||
right: 2rem;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import * as Tabs from '$lib/components/ui/tabs'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import { Label } from '$lib/components/ui/label'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { Lock, LogIn, Server } from '@lucide/svelte'
|
||||
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
|
||||
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
||||
import ConfigBackground from '$lib/components/ConfigBackground.svelte'
|
||||
|
||||
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
|
||||
|
||||
@@ -45,7 +46,7 @@
|
||||
}
|
||||
saveToDesktop()
|
||||
onConnected()
|
||||
} catch (e) {
|
||||
} catch {
|
||||
error = 'Could not reach server — check the URL'
|
||||
} finally {
|
||||
connecting = false
|
||||
@@ -58,13 +59,16 @@
|
||||
try {
|
||||
wails.Call.ByName('SaveConfig', apiUrl.trim(), token.trim())
|
||||
} catch {
|
||||
// ignore — optional desktop-only path
|
||||
// optional desktop-only path
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithAuthentik() {
|
||||
error = ''
|
||||
oidcLoggingIn = true
|
||||
setConfig({ apiUrl: apiUrl.trim(), token: '' })
|
||||
initConfig({ apiUrl: apiUrl.trim(), token: '' })
|
||||
|
||||
try {
|
||||
await startLogin()
|
||||
} catch (e: any) {
|
||||
@@ -78,75 +82,113 @@
|
||||
oidcUser = null
|
||||
oidcConfigured = false
|
||||
}
|
||||
|
||||
// OIDC callback state: user just returned from Authentik
|
||||
let showOidcContinue = $derived(oidcConfigured && oidcUser)
|
||||
</script>
|
||||
|
||||
<div class="flex h-svh items-center justify-center p-6">
|
||||
<Card.Root class="w-full max-w-md">
|
||||
<Card.Header>
|
||||
<Card.Title>Connect to Oikos</Card.Title>
|
||||
<Card.Description>Enter the server URL and your access token.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Tabs.Root value={oidcConfigured ? 'oidc' : 'token'}>
|
||||
<Tabs.List class="mb-4 grid w-full grid-cols-2">
|
||||
<Tabs.Trigger value="token">Token</Tabs.Trigger>
|
||||
<Tabs.Trigger value="oidc">Login with Authentik</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="token">
|
||||
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}>
|
||||
<div class="relative h-svh overflow-hidden bg-background">
|
||||
<ConfigBackground />
|
||||
|
||||
<div class="relative z-10 flex h-full items-center justify-center p-6">
|
||||
<div class="w-full max-w-[26rem] space-y-8 rounded-2xl border border-white/8 bg-card/60 p-8 shadow-2xl shadow-black/40 backdrop-blur-xl">
|
||||
|
||||
<!-- Logo + heading -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<svg viewBox="0 0 91 100" class="h-16 w-16 fill-white/90" aria-hidden="true">
|
||||
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
|
||||
</svg>
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Connect to Oikos</h1>
|
||||
<p class="mt-1.5 text-sm text-muted-foreground">Configure your control room connection</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server URL -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="server-url">Server URL</Label>
|
||||
<Label for="server-url" class="text-xs font-medium">Server URL</Label>
|
||||
<div class="relative">
|
||||
<Server class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
id="server-url"
|
||||
type="url"
|
||||
placeholder="https://oikos.hubris.network (leave blank if same-origin)"
|
||||
placeholder="https://oikos.hubris.network"
|
||||
class="pl-9"
|
||||
bind:value={apiUrl}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="token">Token</Label>
|
||||
<Input id="token" type="password" placeholder="bearer token" bind:value={token} />
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" disabled={connecting} class="flex-1">
|
||||
{connecting ? 'Connecting…' : 'Connect'}
|
||||
|
||||
{#if showOidcContinue}
|
||||
<!-- OIDC authenticated state -->
|
||||
<div class="flex flex-col items-center gap-3 rounded-xl border border-white/5 bg-background/40 p-5">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Logged in as <span class="font-semibold text-foreground">{oidcUser}</span>
|
||||
</p>
|
||||
<div class="flex w-full gap-2">
|
||||
<Button type="button" variant="default" class="flex-1" onclick={() => onConnected()}>
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
{#if onCancel}
|
||||
<Button type="button" variant="outline" onclick={onCancel}>Cancel</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if existing.token}
|
||||
<Button type="button" variant="ghost" size="sm" onclick={disconnect}>
|
||||
Forget saved connection
|
||||
</Button>
|
||||
{/if}
|
||||
</form>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="oidc">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if oidcConfigured && oidcUser}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Logged in as <span class="font-medium text-foreground">{oidcUser}</span>
|
||||
</p>
|
||||
<Button type="button" variant="default" onclick={() => onConnected()}>
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onclick={logoutOIDC}>
|
||||
Log out
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Sign in with your Authentik account to access the control room.
|
||||
</p>
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
<Button type="button" disabled={oidcLoggingIn} onclick={loginWithAuthentik}>
|
||||
{oidcLoggingIn ? 'Redirecting to Authentik…' : 'Login with Authentik'}
|
||||
<!-- Auth options: Authentik + token -->
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<!-- Authentik row -->
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={oidcLoggingIn}
|
||||
onclick={loginWithAuthentik}
|
||||
class="w-full gap-2"
|
||||
>
|
||||
<LogIn class="size-4" />
|
||||
{oidcLoggingIn ? 'Redirecting…' : 'Login with Authentik'}
|
||||
</Button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Separator decorative class="flex-1" />
|
||||
<span class="text-[10px] font-medium uppercase tracking-widest text-muted-foreground/40">or use</span>
|
||||
<Separator decorative class="flex-1" />
|
||||
</div>
|
||||
|
||||
<!-- Token row -->
|
||||
<form
|
||||
class="flex flex-col gap-2.5"
|
||||
onsubmit={(e) => { e.preventDefault(); connect() }}
|
||||
>
|
||||
<div class="relative">
|
||||
<Lock class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Bearer token"
|
||||
class="pl-9"
|
||||
bind:value={token}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={connecting} class="w-full">
|
||||
{connecting ? 'Connecting…' : 'Connect'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<p class="rounded-lg bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Footer: cancel + forget -->
|
||||
{#if !showOidcContinue && (onCancel || existing.token)}
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
{#if onCancel}
|
||||
<Button type="button" variant="ghost" size="sm" onclick={onCancel}>Cancel</Button>
|
||||
{/if}
|
||||
{#if existing.token}
|
||||
<Button type="button" variant="ghost" size="sm" onclick={disconnect}>
|
||||
@@ -154,8 +196,7 @@
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare const __OIKOS_VERSION__: string
|
||||
@@ -1,6 +1,17 @@
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
|
||||
// In Docker, VERSION is copied into the build WORKDIR (/build/web/VERSION).
|
||||
// In local dev, the cwd is web/ and VERSION is two dirs up (../../VERSION
|
||||
// from vite.config.ts location in web/). Try both.
|
||||
let version: string
|
||||
if (existsSync('../VERSION')) {
|
||||
version = readFileSync('../VERSION', 'utf-8').trim()
|
||||
} else {
|
||||
version = readFileSync('VERSION', 'utf-8').trim()
|
||||
}
|
||||
|
||||
// Injects OIKOS_API_TOKEN into proxied /api requests in dev — the API no
|
||||
// longer has a dev-open bypass (plans/2026-07-12-wails-desktop-app.md 0.4),
|
||||
@@ -21,6 +32,9 @@ function authProxy(target: string, rewrite?: (path: string) => string) {
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), svelte()],
|
||||
base: '/',
|
||||
define: {
|
||||
__OIKOS_VERSION__: JSON.stringify(`v${version}`),
|
||||
},
|
||||
resolve: {
|
||||
alias: { $lib: '/src/lib' }
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user