Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394a227877 | ||
|
|
0f84ab5eda | ||
|
|
5b57843346 | ||
|
|
ee1204d76b | ||
|
|
6d58336ce2 | ||
|
|
0785f14220 | ||
|
|
a7db0dd00d | ||
|
|
fb59c390e2 | ||
|
|
00e5f62c8e | ||
|
|
b9d03316fd | ||
|
|
da9bdc4cbb | ||
|
|
dcb9ff4a56 | ||
|
|
0454527b83 |
+3
-62
@@ -14,7 +14,6 @@ jobs:
|
|||||||
environment: staging
|
environment: staging
|
||||||
env:
|
env:
|
||||||
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
||||||
ROUTING_IMAGE: git.d-ma.be/mathias/routing
|
|
||||||
INFRA_REPO: git@git.d-ma.be:mathias/infra.git
|
INFRA_REPO: git@git.d-ma.be:mathias/infra.git
|
||||||
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
||||||
steps:
|
steps:
|
||||||
@@ -41,28 +40,6 @@ jobs:
|
|||||||
|
|
||||||
echo "Built and pushed ${INGESTION_IMAGE}:${IMAGE_TAG}"
|
echo "Built and pushed ${INGESTION_IMAGE}:${IMAGE_TAG}"
|
||||||
|
|
||||||
- name: Build and push routing image
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
trap 'rm -f /tmp/routing-image.tar' EXIT
|
|
||||||
IMAGE_TAG="${{ github.sha }}"
|
|
||||||
echo "Building ${ROUTING_IMAGE}:${IMAGE_TAG}"
|
|
||||||
|
|
||||||
buildctl --addr "${BUILDKIT_HOST}" build \
|
|
||||||
--frontend dockerfile.v0 \
|
|
||||||
--local context=. \
|
|
||||||
--local dockerfile=. \
|
|
||||||
--opt filename=Dockerfile.routing \
|
|
||||||
--opt build-arg:VERSION="${IMAGE_TAG}" \
|
|
||||||
--output type=oci,dest=/tmp/routing-image.tar
|
|
||||||
|
|
||||||
skopeo copy \
|
|
||||||
oci-archive:/tmp/routing-image.tar \
|
|
||||||
docker://${ROUTING_IMAGE}:${IMAGE_TAG} \
|
|
||||||
--dest-creds "${{ secrets.REGISTRY_CREDS }}"
|
|
||||||
|
|
||||||
echo "Built and pushed ${ROUTING_IMAGE}:${IMAGE_TAG}"
|
|
||||||
|
|
||||||
- name: Update infra repo
|
- name: Update infra repo
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
@@ -81,18 +58,14 @@ jobs:
|
|||||||
sed -i "s|git.d-ma.be/mathias/ingestion:.*|git.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
|
sed -i "s|git.d-ma.be/mathias/ingestion:.*|git.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
|
||||||
"k3s/apps/supervisor/ingestion-deployment.yaml"
|
"k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||||
|
|
||||||
sed -i "s|git.d-ma.be/mathias/routing:.*|git.d-ma.be/mathias/routing:${IMAGE_TAG}|" \
|
|
||||||
"k3s/apps/routing/deployment.yaml"
|
|
||||||
|
|
||||||
git config user.email "cd-bot@d-ma.be"
|
git config user.email "cd-bot@d-ma.be"
|
||||||
git config user.name "CD Bot"
|
git config user.name "CD Bot"
|
||||||
git add "k3s/apps/supervisor/ingestion-deployment.yaml" \
|
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||||
"k3s/apps/routing/deployment.yaml"
|
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
||||||
git commit -m "chore(deploy): ingestion+routing → ${IMAGE_TAG}"
|
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "Infra repo updated: ingestion+routing → ${IMAGE_TAG}"
|
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
||||||
|
|
||||||
- name: Trigger Flux reconcile (immediate)
|
- name: Trigger Flux reconcile (immediate)
|
||||||
run: |
|
run: |
|
||||||
@@ -132,35 +105,3 @@ jobs:
|
|||||||
kubectl describe pods -n supervisor -l app=ingestion | tail -40
|
kubectl describe pods -n supervisor -l app=ingestion | tail -40
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Wait for Flux to apply new routing image
|
|
||||||
run: |
|
|
||||||
EXPECTED="git.d-ma.be/mathias/routing:${{ github.sha }}"
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
CURRENT=$(kubectl get deploy routing -n routing \
|
|
||||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
|
||||||
if [ "$CURRENT" = "$EXPECTED" ]; then
|
|
||||||
echo "✓ Flux applied routing image after ${i}s"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
kubectl get deploy routing -n routing \
|
|
||||||
-o jsonpath='{.spec.template.spec.containers[0].image}' \
|
|
||||||
| grep -qx "$EXPECTED" \
|
|
||||||
|| { echo "✗ Flux did not apply routing image within 60s"; exit 1; }
|
|
||||||
|
|
||||||
- name: Verify routing rollout
|
|
||||||
run: |
|
|
||||||
kubectl rollout status deployment/routing \
|
|
||||||
--namespace routing \
|
|
||||||
--timeout=120s \
|
|
||||||
|| {
|
|
||||||
echo "── pod status ──"
|
|
||||||
kubectl get pods -n routing -o wide
|
|
||||||
echo "── events ──"
|
|
||||||
kubectl get events -n routing --sort-by='.lastTimestamp' | tail -20
|
|
||||||
echo "── describe ──"
|
|
||||||
kubectl describe pods -n routing -l app=routing | tail -40
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://git-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
# ── Build stage ───────────────────────────────────────────────────────────────
|
|
||||||
FROM golang:1.26-bookworm AS builder
|
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
|
||||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
|
|
||||||
-o /out/routing ./cmd/routing
|
|
||||||
|
|
||||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
|
||||||
FROM gcr.io/distroless/base-debian12
|
|
||||||
|
|
||||||
COPY --from=builder /out/routing /usr/local/bin/routing
|
|
||||||
COPY config/ /app/config/
|
|
||||||
|
|
||||||
ENV SUPERVISOR_CONFIG_DIR=/app/config/supervisor
|
|
||||||
ENV ROUTING_PORT=3210
|
|
||||||
|
|
||||||
EXPOSE 3210
|
|
||||||
|
|
||||||
USER 65532:65532
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/routing"]
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Icebox — retired code (recoverable, not destroyed)
|
||||||
|
|
||||||
|
Per issue #75 (consolidate to a single harness), the routing-pod path was removed
|
||||||
|
from the live tree. It is **preserved and recoverable**, not deleted without trace.
|
||||||
|
|
||||||
|
## What was iceboxed (2026-07-01, issue #75)
|
||||||
|
|
||||||
|
| Path | Why |
|
||||||
|
|------|-----|
|
||||||
|
| `cmd/routing/` | The routing MCP-server binary; every skill call was wrapped through the broken pass-rate router (`wrap(skillName)`). |
|
||||||
|
| `internal/routing/` | Router / Fetcher / Policy / pass-rate. Signal is survivorship-biased and unusable as-is (infra#174). |
|
||||||
|
| `internal/skills/{review,debug,retrospective,trainer,project}/` | Skill handlers usable **only** through `cmd/routing` (verified: each imported solely by `cmd/routing`). |
|
||||||
|
| `Dockerfile.routing` | Built `cmd/routing` exclusively. |
|
||||||
|
| `.gitea/workflows/cd.yml` (routing steps only) | Removed the routing image build + infra image-bump + Flux-wait/rollout-verify for routing; **ingestion build/deploy is unchanged**. |
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The live minimal harness is `cmd/hyperguild` + `brain-mcp` (+ `gitea-mcp` available) —
|
||||||
|
that is what ran the infra#170 loop-1 experiment (routing/injection machinery off) and
|
||||||
|
closed it twice. `cmd/hyperguild` has **zero** transitive dependency on `internal/routing`
|
||||||
|
or `cmd/routing`'s packages (it imports only `internal/tier`). The routing pass-rate signal
|
||||||
|
is being retired, not resurrected (fresh start, per infra#174).
|
||||||
|
|
||||||
|
## How to recover
|
||||||
|
|
||||||
|
Everything above is preserved at commit `00e5f62` under:
|
||||||
|
|
||||||
|
- **tag** `icebox/cmd-routing-2026-07-01`
|
||||||
|
- **branch** `icebox/cmd-routing`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# inspect
|
||||||
|
git checkout icebox/cmd-routing-2026-07-01
|
||||||
|
|
||||||
|
# restore specific packages onto a branch
|
||||||
|
git checkout icebox/cmd-routing-2026-07-01 -- cmd/routing internal/routing \
|
||||||
|
internal/skills/review internal/skills/debug internal/skills/retrospective \
|
||||||
|
internal/skills/trainer internal/skills/project Dockerfile.routing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deliberately NOT touched here (separate scope)
|
||||||
|
|
||||||
|
- **Live k8s routing deployment** (`infra` repo, `k3s/apps/routing/`) still runs its last
|
||||||
|
image; CD no longer rebuilds/redeploys it. Tearing down that deployment is a separate
|
||||||
|
infra-repo task.
|
||||||
|
- **`internal/skills/{brain,org,sessionlog}/`** — kept per #75; already had no importer
|
||||||
|
(orphaned before this cut), harmless, compile + test green.
|
||||||
|
- **`config/supervisor/{review,debug,retrospective,trainer-*}.md`** — routing skill prompts,
|
||||||
|
now orphaned data; left in place (not code, no build impact).
|
||||||
@@ -112,16 +112,15 @@ Flags:
|
|||||||
- `--out PATH` — output file (default `./.mcp.json`)
|
- `--out PATH` — output file (default `./.mcp.json`)
|
||||||
- `--force` — overwrite an existing file
|
- `--force` — overwrite an existing file
|
||||||
|
|
||||||
Modes:
|
Modes (all list **brain + gitea** — Gitea is the audit-trail invariant of the
|
||||||
|
consolidated single harness, #75):
|
||||||
|
|
||||||
- **cloud** — brain MCP only. Claude Code with no routing.
|
- **cloud** — brain + gitea MCP.
|
||||||
- **client-local** — brain + routing pod. The `routing` entry points at
|
- **client-local** — brain + gitea MCP. (The former `routing` entry pointing at
|
||||||
`koala:30310/mcp` (the routing pod, deployed in Plan 6). The
|
`koala:30310/mcp` was removed — the routing pod is iceboxed, #75.)
|
||||||
`X-Hyperguild-Mode: client-local` header is forward-compat for future
|
- **sovereign** — brain + gitea, with a `_mode_note` explaining that this mode
|
||||||
modes; the pod treats absent or unknown values as `client-local`.
|
primarily uses Crush + LiteLLM and the `.mcp.json` is a Claude Code fallback
|
||||||
- **sovereign** — brain only, with a `_mode_note` explaining that this
|
for emergency offline use.
|
||||||
mode primarily uses Crush + LiteLLM and the `.mcp.json` is a Claude
|
|
||||||
Code fallback for emergency offline use.
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
|
|||||||
+26
-19
@@ -59,31 +59,40 @@ func runMode(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// giteaMCPURL is the public Gitea MCP endpoint (OAuth via Dex/Authentik). Gitea
|
||||||
|
// is the audit-trail invariant of the consolidated single harness (#75), so every
|
||||||
|
// mode lists it as an available connection.
|
||||||
|
const giteaMCPURL = "https://git-mcp.d-ma.be/mcp"
|
||||||
|
|
||||||
|
func brainEntry(brainURL string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"url": brainURL + "/mcp",
|
||||||
|
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func giteaEntry() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"url": giteaMCPURL,
|
||||||
|
"description": "Gitea MCP — issues/PRs/repo ops (audit-trail invariant)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func modeCloud(brainURL string) map[string]any {
|
func modeCloud(brainURL string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"mcpServers": map[string]any{
|
"mcpServers": map[string]any{
|
||||||
"brain": map[string]any{
|
"brain": brainEntry(brainURL),
|
||||||
"url": brainURL + "/mcp",
|
"gitea": giteaEntry(),
|
||||||
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func modeClientLocal(brainURL string) map[string]any {
|
func modeClientLocal(brainURL string) map[string]any {
|
||||||
|
// The routing pod is iceboxed (#75); the consolidated harness is brain + gitea.
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"mcpServers": map[string]any{
|
"mcpServers": map[string]any{
|
||||||
"brain": map[string]any{
|
"brain": brainEntry(brainURL),
|
||||||
"url": brainURL + "/mcp",
|
"gitea": giteaEntry(),
|
||||||
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
|
||||||
},
|
|
||||||
"routing": map[string]any{
|
|
||||||
"url": "http://koala:30310/mcp",
|
|
||||||
"description": "Mode 2 routing pod — routes skill calls to LiteLLM/local",
|
|
||||||
"headers": map[string]any{
|
|
||||||
"X-Hyperguild-Mode": "client-local",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,10 +101,8 @@ func modeSovereign(brainURL string) map[string]any {
|
|||||||
return map[string]any{
|
return map[string]any{
|
||||||
"_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback (e.g. emergency offline editing).",
|
"_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback (e.g. emergency offline editing).",
|
||||||
"mcpServers": map[string]any{
|
"mcpServers": map[string]any{
|
||||||
"brain": map[string]any{
|
"brain": brainEntry(brainURL),
|
||||||
"url": brainURL + "/mcp",
|
"gitea": giteaEntry(),
|
||||||
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-17
@@ -35,11 +35,13 @@ func TestRunMode_Cloud_Default(t *testing.T) {
|
|||||||
servers, ok := got["mcpServers"].(map[string]any)
|
servers, ok := got["mcpServers"].(map[string]any)
|
||||||
require.True(t, ok, "mcpServers must be a JSON object")
|
require.True(t, ok, "mcpServers must be a JSON object")
|
||||||
assert.Contains(t, servers, "brain")
|
assert.Contains(t, servers, "brain")
|
||||||
|
assert.Contains(t, servers, "gitea")
|
||||||
assert.NotContains(t, servers, "routing")
|
assert.NotContains(t, servers, "routing")
|
||||||
assert.NotContains(t, got, "_mode_note")
|
assert.NotContains(t, got, "_mode_note")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMode_ClientLocal_HasRoutingEntry(t *testing.T) {
|
func TestRunMode_ClientLocal_NoRouting_HasGitea(t *testing.T) {
|
||||||
|
// #75: the routing pod is iceboxed; the consolidated harness is brain + gitea.
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
outPath := filepath.Join(dir, ".mcp.json")
|
outPath := filepath.Join(dir, ".mcp.json")
|
||||||
t.Setenv("BRAIN_URL", "http://koala:30330")
|
t.Setenv("BRAIN_URL", "http://koala:30330")
|
||||||
@@ -51,17 +53,11 @@ func TestRunMode_ClientLocal_HasRoutingEntry(t *testing.T) {
|
|||||||
got := readJSON(t, outPath)
|
got := readJSON(t, outPath)
|
||||||
servers := got["mcpServers"].(map[string]any)
|
servers := got["mcpServers"].(map[string]any)
|
||||||
require.Contains(t, servers, "brain")
|
require.Contains(t, servers, "brain")
|
||||||
require.Contains(t, servers, "routing")
|
require.Contains(t, servers, "gitea")
|
||||||
|
assert.NotContains(t, servers, "routing", "routing pod iceboxed (#75)")
|
||||||
routing := servers["routing"].(map[string]any)
|
|
||||||
assert.NotContains(t, routing, "_routing_pending", "placeholder should be removed once Plan 6 ships")
|
|
||||||
|
|
||||||
headers, ok := routing["headers"].(map[string]any)
|
|
||||||
require.True(t, ok, "routing entry should have headers block")
|
|
||||||
assert.Equal(t, "client-local", headers["X-Hyperguild-Mode"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModeClientLocalHasRoutingHeader(t *testing.T) {
|
func TestModeGiteaEntryPresentAndWellFormed(t *testing.T) {
|
||||||
tmp := t.TempDir() + "/mcp.json"
|
tmp := t.TempDir() + "/mcp.json"
|
||||||
out := &bytes.Buffer{}
|
out := &bytes.Buffer{}
|
||||||
stderr := &bytes.Buffer{}
|
stderr := &bytes.Buffer{}
|
||||||
@@ -73,13 +69,10 @@ func TestModeClientLocalHasRoutingHeader(t *testing.T) {
|
|||||||
require.NoError(t, json.Unmarshal(body, &doc))
|
require.NoError(t, json.Unmarshal(body, &doc))
|
||||||
|
|
||||||
servers := doc["mcpServers"].(map[string]any)
|
servers := doc["mcpServers"].(map[string]any)
|
||||||
routing := servers["routing"].(map[string]any)
|
require.NotContains(t, servers, "routing")
|
||||||
assert.Equal(t, "http://koala:30310/mcp", routing["url"])
|
gitea, ok := servers["gitea"].(map[string]any)
|
||||||
assert.NotContains(t, routing, "_routing_pending", "placeholder should be removed once Plan 6 ships")
|
require.True(t, ok, "gitea entry must be present (audit-trail invariant)")
|
||||||
|
assert.Equal(t, "https://git-mcp.d-ma.be/mcp", gitea["url"])
|
||||||
headers, ok := routing["headers"].(map[string]any)
|
|
||||||
require.True(t, ok, "routing entry should have headers block")
|
|
||||||
assert.Equal(t, "client-local", headers["X-Hyperguild-Mode"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
||||||
@@ -94,6 +87,7 @@ func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
|||||||
assert.Contains(t, got, "_mode_note")
|
assert.Contains(t, got, "_mode_note")
|
||||||
servers := got["mcpServers"].(map[string]any)
|
servers := got["mcpServers"].(map[string]any)
|
||||||
assert.Contains(t, servers, "brain")
|
assert.Contains(t, servers, "brain")
|
||||||
|
assert.Contains(t, servers, "gitea")
|
||||||
assert.NotContains(t, servers, "routing")
|
assert.NotContains(t, servers, "routing")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// The internal/skills/{debug,retrospective,review,trainer} packages imported
|
|
||||||
// below are also imported by cmd/supervisor. Plan 7 (supervisor retirement)
|
|
||||||
// MUST NOT delete these four packages — the routing pod is their second
|
|
||||||
// consumer. Plan 7 deletes only internal/skills/{tdd,spec,tier} (the skills
|
|
||||||
// that don't route to local), the supervisor binary, and supervisor manifests.
|
|
||||||
// See docs/superpowers/specs/2026-05-04-mode-2-routing-pod-design.md (Constraints).
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/auth"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/config"
|
|
||||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/githubclient"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/mcp"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/routing"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/debug"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/project"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/review"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/trainer"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
||||||
slog.SetDefault(logger)
|
|
||||||
|
|
||||||
cfg, err := config.LoadRouting()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("config load failed", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
configDir := envOr("SUPERVISOR_CONFIG_DIR", "/app/config/supervisor")
|
|
||||||
mustRead := func(path string) string {
|
|
||||||
b, err := os.ReadFile(configDir + "/" + path)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read prompt failed", "path", path, "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
llm := iexec.NewLiteLLM(cfg.LiteLLMBaseURL, cfg.LiteLLMAPIKey, 0)
|
|
||||||
|
|
||||||
router := &routing.Router{
|
|
||||||
Fetcher: routing.NewFetcher(cfg.BrainURL, "7d", time.Duration(cfg.PassRateTTLSeconds)*time.Second),
|
|
||||||
Logger: routing.NewLogger(cfg.BrainURL),
|
|
||||||
Policy: routing.Policy{Floor: cfg.RouteLocalFloor, Ceil: cfg.RouteLocalCeil},
|
|
||||||
FastModel: cfg.FastModel,
|
|
||||||
ThinkingModel: cfg.ThinkingModel,
|
|
||||||
Complete: llm.Complete,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skill packages call CompleteFunc(ctx, model, system, user) — no session_id
|
|
||||||
// or project_root in the signature. Rather than modifying every skill's API
|
|
||||||
// (and inflating Plan 6's blast radius), the routing pod logs every decision
|
|
||||||
// under a fixed session_id "_routing". Operators query
|
|
||||||
// `GET /pass-rate?skill=_routing&window=...` to inspect routing health.
|
|
||||||
const routingSessionID = "_routing"
|
|
||||||
wrap := func(skillName string) routing.CompleteFunc {
|
|
||||||
return func(ctx context.Context, _, system, user string) (string, int64, error) {
|
|
||||||
// The model param is ignored: the router picks the model based on policy.
|
|
||||||
return router.Run(ctx, routing.RunInput{
|
|
||||||
Skill: skillName,
|
|
||||||
System: system,
|
|
||||||
User: user,
|
|
||||||
SessionID: routingSessionID,
|
|
||||||
ProjectRoot: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := registry.New()
|
|
||||||
reg.Register(review.New(review.Config{
|
|
||||||
SkillPrompt: mustRead("review.md"),
|
|
||||||
DefaultModel: cfg.FastModel,
|
|
||||||
CompleteFunc: review.CompleteFunc(wrap("review")),
|
|
||||||
}))
|
|
||||||
reg.Register(debug.New(debug.Config{
|
|
||||||
SkillPrompt: mustRead("debug.md"),
|
|
||||||
DefaultModel: cfg.FastModel,
|
|
||||||
CompleteFunc: debug.CompleteFunc(wrap("debug")),
|
|
||||||
}))
|
|
||||||
reg.Register(retrospective.New(retrospective.Config{
|
|
||||||
SkillPrompt: mustRead("retrospective.md"),
|
|
||||||
DefaultModel: cfg.FastModel,
|
|
||||||
CompleteFunc: retrospective.CompleteFunc(wrap("retrospective")),
|
|
||||||
}))
|
|
||||||
reg.Register(trainer.New(trainer.Config{
|
|
||||||
ReaderPrompt: mustRead("trainer-reader.md"),
|
|
||||||
WriterPrompt: mustRead("trainer-writer.md"),
|
|
||||||
DefaultModel: cfg.FastModel,
|
|
||||||
CompleteFunc: trainer.CompleteFunc(wrap("trainer")),
|
|
||||||
}))
|
|
||||||
|
|
||||||
if cfg.GiteaMCPURL != "" {
|
|
||||||
mcpC, err := mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("mcpclient init for project_create — GITEA_MCP_URL is set but GITEA_MCP_TOKEN is empty (check routing-secrets)", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
var ghClient *githubclient.Client
|
|
||||||
if cfg.GitHubPAT != "" {
|
|
||||||
ghClient = githubclient.New(cfg.GitHubPAT)
|
|
||||||
}
|
|
||||||
reg.Register(project.New(project.Config{
|
|
||||||
Client: mcpC,
|
|
||||||
GitHub: ghClient,
|
|
||||||
GiteaOwner: cfg.GiteaOwner,
|
|
||||||
GitHubOwner: cfg.GitHubOwner,
|
|
||||||
GitHubPAT: cfg.GitHubPAT,
|
|
||||||
InfraRepo: cfg.InfraRepo,
|
|
||||||
}))
|
|
||||||
logger.Info("project_create registered", "gitea_mcp_url", cfg.GiteaMCPURL,
|
|
||||||
"gitea_owner", cfg.GiteaOwner, "github_owner", cfg.GitHubOwner,
|
|
||||||
"infra_repo", cfg.InfraRepo, "github_pat_set", cfg.GitHubPAT != "")
|
|
||||||
} else {
|
|
||||||
logger.Info("project_create skipped — GITEA_MCP_URL not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
var validator *auth.Validator
|
|
||||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
|
||||||
audience := os.Getenv("MCP_AUDIENCE")
|
|
||||||
v, err := auth.NewValidator(dexURL, audience)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("build jwt validator", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
validator = v
|
|
||||||
logger.Info("jwt auth enabled", "issuer", dexURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := mcp.NewServer(reg, cfg.MCPAuthToken, validator)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.Handle("/mcp", srv)
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
|
||||||
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
|
||||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
|
||||||
auth.ProtectedResourceHandler(resourceURL, dexURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := ":" + cfg.Port
|
|
||||||
logger.Info("routing pod starting", "addr", addr,
|
|
||||||
"fast", cfg.FastModel, "thinking", cfg.ThinkingModel,
|
|
||||||
"floor", cfg.RouteLocalFloor, "ceil", cfg.RouteLocalCeil)
|
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil { //nolint:gosec
|
|
||||||
logger.Error("server stopped", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func envOr(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
package main_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRoutingPodEndToEnd boots the binary against fake LiteLLM + brain servers,
|
|
||||||
// calls tools/list and one tools/call, and verifies the brain saw a session_log POST.
|
|
||||||
func TestRoutingPodEndToEnd(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("end-to-end binary boot")
|
|
||||||
}
|
|
||||||
|
|
||||||
var brainHits int
|
|
||||||
llm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "stub"}}},
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer llm.Close()
|
|
||||||
|
|
||||||
brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/pass-rate":
|
|
||||||
brainHits++
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.95})
|
|
||||||
case "/mcp":
|
|
||||||
brainHits++
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}})
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer brain.Close()
|
|
||||||
|
|
||||||
port := freePort(t)
|
|
||||||
addr := "127.0.0.1:" + port
|
|
||||||
baseURL := "http://" + addr
|
|
||||||
|
|
||||||
bin := buildRouting(t)
|
|
||||||
cmd := exec.Command(bin)
|
|
||||||
cmd.Env = []string{
|
|
||||||
"ROUTING_PORT=" + port,
|
|
||||||
"LITELLM_BASE_URL=" + llm.URL,
|
|
||||||
"LITELLM_API_KEY=stub",
|
|
||||||
"BRAIN_URL=" + brain.URL,
|
|
||||||
"SUPERVISOR_CONFIG_DIR=../../config/supervisor",
|
|
||||||
"PATH=" + os.Getenv("PATH"),
|
|
||||||
"HOME=" + os.Getenv("HOME"),
|
|
||||||
}
|
|
||||||
require.NoError(t, cmd.Start())
|
|
||||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
|
||||||
|
|
||||||
require.NoError(t, waitForPort(t, addr, 30*time.Second))
|
|
||||||
|
|
||||||
resp := mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
|
||||||
assert.Contains(t, resp, `"review"`)
|
|
||||||
assert.Contains(t, resp, `"debug"`)
|
|
||||||
assert.Contains(t, resp, `"retrospective"`)
|
|
||||||
assert.Contains(t, resp, `"trainer"`)
|
|
||||||
|
|
||||||
resp = mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"review","arguments":{"project_root":"/tmp","files":["README.md"]}}}`)
|
|
||||||
_ = resp // shape varies by skill; we only need a 200
|
|
||||||
|
|
||||||
// Wait briefly for the async session_log to land.
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) && brainHits < 2 {
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
}
|
|
||||||
assert.GreaterOrEqual(t, brainHits, 2, "expected at least one /pass-rate hit and one /mcp session_log hit")
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildRouting(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
bin := t.TempDir() + "/routing"
|
|
||||||
out, err := exec.Command("go", "build", "-o", bin, "github.com/mathiasbq/supervisor/cmd/routing").CombinedOutput()
|
|
||||||
require.NoError(t, err, "build failed: %s", out)
|
|
||||||
return bin
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForPort(_ *testing.T, addr string, dur time.Duration) error {
|
|
||||||
deadline := time.Now().Add(dur)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
c, err := http.Get("http://" + addr + "/healthz") //nolint:noctx
|
|
||||||
if err == nil {
|
|
||||||
_ = c.Body.Close()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
conn, err := http.NewRequest(http.MethodPost, "http://"+addr+"/mcp", strings.NewReader(`{}`))
|
|
||||||
if err == nil {
|
|
||||||
r, err := http.DefaultClient.Do(conn)
|
|
||||||
if err == nil {
|
|
||||||
_ = r.Body.Close()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
}
|
|
||||||
return context.DeadlineExceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpCall(t *testing.T, url, body string) string {
|
|
||||||
t.Helper()
|
|
||||||
r, err := http.Post(url, "application/json", strings.NewReader(body)) //nolint:noctx
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = r.Body.Close() }()
|
|
||||||
raw, err := io.ReadAll(r.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// freePort grabs an OS-assigned TCP port and releases it. There is a small
|
|
||||||
// race window before the subprocess re-binds it, but it is acceptable for
|
|
||||||
// test isolation against a hardcoded port colliding with another test or
|
|
||||||
// stray process.
|
|
||||||
func freePort(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
port := l.Addr().(*net.TCPAddr).Port
|
|
||||||
require.NoError(t, l.Close())
|
|
||||||
return strconv.Itoa(port)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Runbook: exercising review/debug traffic to fill the pass-rate dataset
|
||||||
|
|
||||||
|
**Why this exists:** the routing pod's local-vs-cloud decision is gated on a
|
||||||
|
pass-rate history that only accrues from real `review`/`debug` invocations
|
||||||
|
**through the pod**. Until the dataset has data, the fast (local) path never
|
||||||
|
activates and the core hypothesis (hyperguild #35) can't be validated. This
|
||||||
|
runbook is how you spin that flywheel.
|
||||||
|
|
||||||
|
## The one trap
|
||||||
|
|
||||||
|
Pass-rate accrues **only** when a skill tool is called via the routing pod's MCP
|
||||||
|
endpoint. These look like they should count but **do not**:
|
||||||
|
|
||||||
|
- **Crush** — talks to LiteLLM directly, bypasses the pod. No log.
|
||||||
|
- **claude.ai web / Claude Desktop without the connector** — no log.
|
||||||
|
- **Running the local `code-review` / `debug` skills** (`~/dev/.skills`) inline in
|
||||||
|
a Claude Code session — those are local skills, not the pod's MCP tools. No log.
|
||||||
|
|
||||||
|
Only a `tools/call` to the routing pod records a pass/fail.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Purpose | URL | Auth |
|
||||||
|
|---------|-----|------|
|
||||||
|
| Routing MCP (local, Tailscale) | `http://koala:30310/mcp` | Bearer `ROUTING_MCP_TOKEN` |
|
||||||
|
| Routing MCP (remote) | `https://routing-mcp.d-ma.be/mcp` | OAuth via `auth.d-ma.be` (audience `claude-ai`) |
|
||||||
|
| Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | none (read-only) |
|
||||||
|
|
||||||
|
Tools advertised: **`review`**, **`debug`** (the two the #35 gate measures),
|
||||||
|
plus `session_log`, `retrospective`, `trainer`.
|
||||||
|
|
||||||
|
## Step 1 — connect the routing pod as an MCP server
|
||||||
|
|
||||||
|
**Local** (needs the bearer token; keep it out of argv via 1Password):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
op run --env-file ~/.op-env -- \
|
||||||
|
claude mcp add routing --transport http http://koala:30310/mcp \
|
||||||
|
--header "Authorization: Bearer $ROUTING_MCP_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remote** (claude.ai / Claude Desktop): add a custom connector pointing at
|
||||||
|
`https://routing-mcp.d-ma.be/mcp`; it completes OAuth against `auth.d-ma.be`,
|
||||||
|
no static token.
|
||||||
|
|
||||||
|
Verify: a `tools/list` should return `review`, `debug`, `session_log`,
|
||||||
|
`retrospective`, `trainer`.
|
||||||
|
|
||||||
|
## Step 2 — route real work through it
|
||||||
|
|
||||||
|
In normal sessions, invoke the pod's tools instead of reviewing/debugging inline:
|
||||||
|
|
||||||
|
- *"Use the **routing** `review` tool on this diff."*
|
||||||
|
- *"**debug** this failure through the routing pod."*
|
||||||
|
|
||||||
|
Each call logs an outcome to ingestion → `/pass-rate` ticks up.
|
||||||
|
|
||||||
|
## Step 3 — how routing actually picks the model
|
||||||
|
|
||||||
|
Per `internal/routing/policy.go`:
|
||||||
|
|
||||||
|
1. pass-rate `nil` (cold) → **local** fast tier. The router defaults to local
|
||||||
|
from invocation #1, not to cloud — so the fast tier is exercised immediately.
|
||||||
|
2. pass-rate `>= 0.90` (floor) → **local**; `< 0.70` (ceil) → **cloud/thinking**;
|
||||||
|
in the `[0.70, 0.90)` band a request-hash bit samples 50/50.
|
||||||
|
3. On a local execution error the router falls open to the thinking model for
|
||||||
|
that one call (logged `thinking_fallback`).
|
||||||
|
|
||||||
|
So you are not "paying in on cloud" — cold calls already run on the (validated)
|
||||||
|
local fast tier **`koala/qwen36-35b-a3b`** (Qwen3.6-35B-A3B MTP, promoted
|
||||||
|
2026-06-29, infra `c66a195`, `HYPERGUILD_FAST_MODEL`). Accumulating passes just
|
||||||
|
keeps it there once real pass-rate is computed.
|
||||||
|
|
||||||
|
> **Instrumentation note (#73, fixed 2026-06-30):** until v0.11.1 the pod logged
|
||||||
|
> successes as `"skip"` (not `"pass"`), under `skill:"_routing"`, via an
|
||||||
|
> unauthenticated POST that silently 401'd — so `/pass-rate` stayed at zero no
|
||||||
|
> matter how much you used it. That's fixed and verified (a real review call now
|
||||||
|
> moves `/pass-rate?skill=review` 0→1). If you see traffic not registering,
|
||||||
|
> re-check #73's three failure modes first.
|
||||||
|
|
||||||
|
## Target & verification
|
||||||
|
|
||||||
|
- **50 logged invocations** across `review` + `debug` within the 14-day window.
|
||||||
|
The clock restarts **2026-06-30** (the day instrumentation was verified working;
|
||||||
|
the original 2026-06-26→07-10 window measured broken plumbing) → **kill-date
|
||||||
|
2026-07-14**, ~4 calls/day (1 already logged from the #73 smoke test).
|
||||||
|
- Check progress anytime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://koala:30330/pass-rate?skill=review"
|
||||||
|
curl -s "http://koala:30330/pass-rate?skill=debug"
|
||||||
|
```
|
||||||
|
|
||||||
|
- If ~4–5/day isn't realistic alongside Crush, that is **not** a failure — per
|
||||||
|
#35 deliverable #1 it's the signal hyperguild isn't on the work critical path,
|
||||||
|
and the pre-decided **Berget fallback** (`gpt-oss-120b` / `qwen3-32b`) carries
|
||||||
|
the fast tier instead.
|
||||||
|
|
||||||
|
## Refs
|
||||||
|
|
||||||
|
- hyperguild #35 — the validation issue (data gate = deliverable #1)
|
||||||
|
- `docs/multi-model-routing.md` — routing policy
|
||||||
|
- brain: `wiki/homelab/hypotheses/qwen36-35b-a3b-fast-model-experiment-2026-05-28.md`
|
||||||
|
- infra `c66a195` — qwen36 promotion; `models.yml` / `llama-swap-configmap.yaml`
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
@@ -34,12 +34,33 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
// claudeSink converts each claudewatcher.Batch into one wiki note under
|
// kubeClient builds an in-cluster Kubernetes client (falls back to
|
||||||
// brain/wiki/claude-sessions/facts/. v1 emits one note per session
|
// $KUBECONFIG for local dev/testing against a real cluster).
|
||||||
// keyed by host + session id; classifier-driven hall routing is a
|
func kubeClient() (kubernetes.Interface, error) {
|
||||||
// follow-up (hyperguild#27 v2).
|
if cfg, err := rest.InClusterConfig(); err == nil {
|
||||||
|
return kubernetes.NewForConfig(cfg)
|
||||||
|
}
|
||||||
|
rules := clientcmd.NewDefaultClientConfigLoadingRules()
|
||||||
|
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{})
|
||||||
|
cfg, err := cc.ClientConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kube config (no in-cluster, no kubeconfig): %w", err)
|
||||||
|
}
|
||||||
|
return kubernetes.NewForConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// claudeSink converts each claudewatcher.Batch into a raw session dump
|
||||||
|
// under brain/archive/claude-sessions/<host>/. Deliberately NOT a wiki
|
||||||
|
// note (api.WriteNote / brain/wiki/) — raw full transcripts out-ranked
|
||||||
|
// curated ai-sessions summaries in BM25 (177,818 vs 45,335 on the same
|
||||||
|
// query) and duplicated content already summarized elsewhere. Kept for
|
||||||
|
// deep lookups, never indexed. See ai-sessions#10.
|
||||||
type claudeSink struct {
|
type claudeSink struct {
|
||||||
brainDir string
|
brainDir string
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
@@ -67,16 +88,14 @@ func (s *claudeSink) Ingest(ctx context.Context, b claudewatcher.Batch) error {
|
|||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
slug := "session-" + b.Host + "-" + b.SessionID
|
slug := "session-" + b.Host + "-" + b.SessionID
|
||||||
if _, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
dest := filepath.Join(s.brainDir, "archive", "claude-sessions", b.Host, slug+".md")
|
||||||
Filename: slug,
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
Wing: "claude-sessions",
|
return fmt.Errorf("create claude-sessions archive dir: %w", err)
|
||||||
Hall: "facts",
|
|
||||||
Type: "source",
|
|
||||||
Domain: b.ProjectID,
|
|
||||||
Content: sb.String(),
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("write claude session note: %w", err)
|
|
||||||
}
|
}
|
||||||
|
if err := os.WriteFile(dest, []byte(sb.String()), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write claude session archive: %w", err)
|
||||||
|
}
|
||||||
|
s.logger.Debug("claude session archived (non-indexed)", "path", dest)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +371,29 @@ func main() {
|
|||||||
logger.Info("claudewatcher started",
|
logger.Info("claudewatcher started",
|
||||||
"sessions_dir", claudeDir, "host", host, "interval", interval)
|
"sessions_dir", claudeDir, "host", host, "interval", interval)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gitea push webhook -> on-demand brain-sync Job, instead of waiting up
|
||||||
|
// to 15 minutes for the next CronJob poll. Off by default (opt in via
|
||||||
|
// GITEA_WEBHOOK_SECRET) since it needs Job-create RBAC in the "brain"
|
||||||
|
// namespace that a fresh deploy won't have granted yet.
|
||||||
|
var webhookHandler *webhook.Handler
|
||||||
|
if webhookSecret := os.Getenv("GITEA_WEBHOOK_SECRET"); webhookSecret != "" {
|
||||||
|
kc, err := kubeClient()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("brain-sync webhook: kube client", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
webhookHandler = &webhook.Handler{
|
||||||
|
Secret: webhookSecret,
|
||||||
|
Clientset: kc,
|
||||||
|
Namespace: envOr("BRAIN_SYNC_NAMESPACE", "brain"),
|
||||||
|
CronJobName: envOr("BRAIN_SYNC_CRONJOB", "brain-sync"),
|
||||||
|
WatchRepo: envOr("BRAIN_SYNC_WATCH_REPO", "mathias/brain"),
|
||||||
|
Logger: logger,
|
||||||
|
}
|
||||||
|
logger.Info("brain-sync webhook enabled", "namespace", webhookHandler.Namespace, "cronjob", webhookHandler.CronJobName)
|
||||||
|
}
|
||||||
|
|
||||||
if vectorStore != nil {
|
if vectorStore != nil {
|
||||||
embedSyncInterval := envInt("BRAIN_EMBED_SYNC_INTERVAL", 300)
|
embedSyncInterval := envInt("BRAIN_EMBED_SYNC_INTERVAL", 300)
|
||||||
vectorstore.StartSync(ctx, brainDir, vectorStore,
|
vectorstore.StartSync(ctx, brainDir, vectorStore,
|
||||||
@@ -373,6 +415,9 @@ func main() {
|
|||||||
mux.HandleFunc("POST /promote", h.Promote)
|
mux.HandleFunc("POST /promote", h.Promote)
|
||||||
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||||
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
||||||
|
if webhookHandler != nil {
|
||||||
|
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
||||||
|
}
|
||||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("build jwt validator", "err", err)
|
logger.Error("build jwt validator", "err", err)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// ingestion/cmd/server/main_test.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClaudeSink_IngestWritesToNonIndexedArchiveNotWiki(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sink := &claudeSink{brainDir: dir, logger: slog.New(slog.NewTextHandler(os.Stderr, nil))}
|
||||||
|
|
||||||
|
err := sink.Ingest(context.Background(), claudewatcher.Batch{
|
||||||
|
Host: "koala",
|
||||||
|
FilePath: "/host-home-claude/projects/-home-mathias-dev/abc.jsonl",
|
||||||
|
SessionID: "abc",
|
||||||
|
ProjectID: "-home-mathias-dev",
|
||||||
|
Turns: []claudewatcher.Turn{
|
||||||
|
{Type: "assistant", Content: "did a thing"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "archive", "claude-sessions", "koala", "session-koala-abc.md"))
|
||||||
|
require.NoError(t, err, "raw session dump must land in the non-indexed archive")
|
||||||
|
assert.Contains(t, string(got), "did a thing")
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(dir, "wiki", "claude-sessions"))
|
||||||
|
assert.True(t, os.IsNotExist(err), "raw transcripts must never land under wiki/ (ai-sessions#10 — BM25 pollution)")
|
||||||
|
}
|
||||||
+49
-6
@@ -3,29 +3,72 @@ module github.com/mathiasbq/hyperguild/ingestion
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
k8s.io/api v0.31.3
|
||||||
|
k8s.io/apimachinery v0.31.3
|
||||||
|
k8s.io/client-go v0.31.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/swag v0.22.4 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/google/gnostic-models v0.6.8 // indirect
|
||||||
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/imdario/mergo v0.3.6 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
golang.org/x/net v0.26.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.21.0 // indirect
|
||||||
|
golang.org/x/term v0.28.0 // indirect
|
||||||
|
golang.org/x/time v0.3.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
|
||||||
|
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.2.0
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/segmentio/asm v1.2.0 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
golang.org/x/crypto v0.32.0 // indirect
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
+143
-5
@@ -1,12 +1,47 @@
|
|||||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY=
|
git.d-ma.be/mathias/mcp-chassis v0.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo=
|
||||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE=
|
git.d-ma.be/mathias/mcp-chassis v0.2.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||||
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
|
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
|
||||||
|
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
|
||||||
|
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM=
|
||||||
|
github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
|
||||||
|
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -15,6 +50,19 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
|||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||||
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
@@ -27,28 +75,118 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
|
|||||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
|
||||||
|
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||||
|
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||||
|
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||||
|
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||||
|
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||||
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||||
|
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
k8s.io/api v0.31.3 h1:umzm5o8lFbdN/hIXbrK9oRpOproJO62CV1zqxXrLgk8=
|
||||||
|
k8s.io/api v0.31.3/go.mod h1:UJrkIp9pnMOI9K2nlL6vwpxRzzEX5sWgn8kGQe92kCE=
|
||||||
|
k8s.io/apimachinery v0.31.3 h1:6l0WhcYgasZ/wk9ktLq5vLaoXJJr5ts6lkaQzgeYPq4=
|
||||||
|
k8s.io/apimachinery v0.31.3/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
|
||||||
|
k8s.io/client-go v0.31.3 h1:CAlZuM+PH2cm+86LOBemaJI/lQ5linJ6UFxKX/SoG+4=
|
||||||
|
k8s.io/client-go v0.31.3/go.mod h1:2CgjPUTpv3fE5dNygAr2NcM8nhHzXvxB8KL5gYc3kJs=
|
||||||
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
|
||||||
|
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
|
||||||
|
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
|
||||||
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
|
||||||
|
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||||
|
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||||
|
|||||||
@@ -51,12 +51,13 @@ type queryRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type writeRequest struct {
|
type writeRequest struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Domain string `json:"domain,omitempty"`
|
Domain string `json:"domain,omitempty"`
|
||||||
Wing string `json:"wing,omitempty"`
|
Wing string `json:"wing,omitempty"`
|
||||||
Hall string `json:"hall,omitempty"`
|
Hall string `json:"hall,omitempty"`
|
||||||
|
SourceType string `json:"source_type,omitempty"` // "external" opts a hall=facts entry out of the internal default
|
||||||
}
|
}
|
||||||
|
|
||||||
type ingestRequest struct {
|
type ingestRequest struct {
|
||||||
@@ -115,12 +116,13 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
|||||||
// When either is empty, the note falls back to brain/knowledge/<filename>
|
// When either is empty, the note falls back to brain/knowledge/<filename>
|
||||||
// with optional type/domain frontmatter (legacy behaviour).
|
// with optional type/domain frontmatter (legacy behaviour).
|
||||||
type WriteNoteOptions struct {
|
type WriteNoteOptions struct {
|
||||||
Content string
|
Content string
|
||||||
Filename string
|
Filename string
|
||||||
Type string
|
Type string
|
||||||
Domain string
|
Domain string
|
||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
|
SourceType string // "internal" marks a first-party observation (e.g. claudewatcher) that needs no external citation
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteNote writes a markdown note into the brain. Returns the path
|
// WriteNote writes a markdown note into the brain. Returns the path
|
||||||
@@ -165,6 +167,17 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
if opts.Domain != "" {
|
if opts.Domain != "" {
|
||||||
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
||||||
}
|
}
|
||||||
|
sourceType := opts.SourceType
|
||||||
|
if sourceType == "" && opts.Hall == "facts" {
|
||||||
|
// Most hall=facts entries are first-party (an eval/benchmark the
|
||||||
|
// writer ran itself), not external claims — default to internal and
|
||||||
|
// require an explicit source_type: external opt-out for the rare
|
||||||
|
// citation-needing entry (brain-gardener#7).
|
||||||
|
sourceType = "internal"
|
||||||
|
}
|
||||||
|
if sourceType != "" {
|
||||||
|
fmt.Fprintf(&fm, "source_type: %s\n", sourceType)
|
||||||
|
}
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
|
|
||||||
if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil {
|
if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil {
|
||||||
|
|||||||
@@ -118,6 +118,74 @@ func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
|
|||||||
assert.Contains(t, string(content), "Some learning.")
|
assert.Contains(t, string(content), "Some learning.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "# Claude session abc (koala)\n\nBody.\n",
|
||||||
|
Filename: "session-koala-abc",
|
||||||
|
Wing: "claude-sessions",
|
||||||
|
Hall: "facts",
|
||||||
|
Type: "source",
|
||||||
|
SourceType: "internal",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "source_type: internal")
|
||||||
|
assert.Contains(t, string(got), "wing: claude-sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallFactsDefaultsSourceTypeInternalWhenUnset(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "manually captured fact.\n",
|
||||||
|
Wing: "agentsquad",
|
||||||
|
Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Most hall=facts entries are first-party (an eval/benchmark the agent ran
|
||||||
|
// itself), not external claims — default to internal, require explicit
|
||||||
|
// opt-out for the rare case that does need a citation (brain-gardener#7).
|
||||||
|
assert.Contains(t, string(got), "source_type: internal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallFactsPreservesExplicitExternalSourceType(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "vendor pricing claim, needs a citation.\n",
|
||||||
|
Wing: "agentsquad",
|
||||||
|
Hall: "facts",
|
||||||
|
SourceType: "external",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "source_type: external")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallRouteOmitsSourceTypeForNonFactsHalls(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "a decision record.\n",
|
||||||
|
Wing: "agentsquad",
|
||||||
|
Hall: "decisions",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(got), "source_type")
|
||||||
|
}
|
||||||
|
|
||||||
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
||||||
dir, h := setup(t)
|
dir, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Package webhook triggers an on-demand brain-sync Job when Gitea pushes to
|
||||||
|
// mathias/brain, instead of waiting for the next 15-minute CronJob poll.
|
||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
batchv1 "k8s.io/api/batch/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifySignature checks a Gitea webhook's X-Gitea-Signature header: a
|
||||||
|
// hex-encoded HMAC-SHA256 of the raw request body, keyed by the shared
|
||||||
|
// webhook secret. Constant-time compare — timing must not leak how much of
|
||||||
|
// the signature matched.
|
||||||
|
func VerifySignature(payload []byte, signatureHeader, secret string) bool {
|
||||||
|
if signatureHeader == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(payload)
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
return hmac.Equal([]byte(expected), []byte(signatureHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushEvent is the subset of Gitea's push webhook payload this handler needs.
|
||||||
|
type pushEvent struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Repo struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
} `json:"repository"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerJobFromCronJob reads the named CronJob's job template and creates a
|
||||||
|
// new, uniquely-named Job from it — the same thing `kubectl create job
|
||||||
|
// --from=cronjob/<name>` does. Reuses the CronJob's already-tested script
|
||||||
|
// rather than re-implementing sync logic here.
|
||||||
|
func TriggerJobFromCronJob(ctx context.Context, cs kubernetes.Interface, namespace, cronJobName string) (string, error) {
|
||||||
|
cj, err := cs.BatchV1().CronJobs(namespace).Get(ctx, cronJobName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get cronjob %s/%s: %w", namespace, cronJobName, err)
|
||||||
|
}
|
||||||
|
job := &batchv1.Job{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
GenerateName: cronJobName + "-webhook-",
|
||||||
|
Namespace: namespace,
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"triggered-by": "brain-webhook",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: cj.Spec.JobTemplate.Spec,
|
||||||
|
}
|
||||||
|
created, err := cs.BatchV1().Jobs(namespace).Create(ctx, job, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create job from cronjob %s/%s: %w", namespace, cronJobName, err)
|
||||||
|
}
|
||||||
|
return created.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler is the HTTP handler for Gitea's push webhook on mathias/brain.
|
||||||
|
type Handler struct {
|
||||||
|
Secret string
|
||||||
|
Clientset kubernetes.Interface
|
||||||
|
Namespace string // e.g. "brain"
|
||||||
|
CronJobName string // e.g. "brain-sync"
|
||||||
|
WatchRepo string // e.g. "mathias/brain"
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sig := r.Header.Get("X-Gitea-Signature")
|
||||||
|
if !VerifySignature(body, sig, h.Secret) {
|
||||||
|
http.Error(w, "bad signature", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ev pushEvent
|
||||||
|
if err := json.Unmarshal(body, &ev); err != nil {
|
||||||
|
http.Error(w, "bad payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ev.Repo.FullName != h.WatchRepo || ev.Ref != "refs/heads/main" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, "ignored: repo=%s ref=%s", ev.Repo.FullName, ev.Ref)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobName, err := TriggerJobFromCronJob(r.Context(), h.Clientset, h.Namespace, h.CronJobName)
|
||||||
|
if err != nil {
|
||||||
|
h.Logger.Error("webhook: trigger job failed", "err", err)
|
||||||
|
http.Error(w, "trigger failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Logger.Info("webhook: triggered brain-sync job", "job", jobName)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, "triggered %s", jobName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
batchv1 "k8s.io/api/batch/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/client-go/kubernetes/fake"
|
||||||
|
k8stesting "k8s.io/client-go/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newFakeClientset simulates the real API server's GenerateName expansion,
|
||||||
|
// which the plain fake clientset tracker does not do on its own -- without
|
||||||
|
// this, every created Job keeps an empty Name (a fake-clientset limitation,
|
||||||
|
// not real API server behavior).
|
||||||
|
func newFakeClientset(objects ...runtime.Object) *fake.Clientset {
|
||||||
|
cs := fake.NewSimpleClientset(objects...)
|
||||||
|
cs.PrependReactor("create", "jobs", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
||||||
|
createAction := action.(k8stesting.CreateAction)
|
||||||
|
job, ok := createAction.GetObject().(*batchv1.Job)
|
||||||
|
if ok && job.Name == "" && job.GenerateName != "" {
|
||||||
|
job.Name = job.GenerateName + "test0001"
|
||||||
|
}
|
||||||
|
return false, nil, nil // not "handled" -- let the default reactor store it
|
||||||
|
})
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(t *testing.T, payload []byte, secret string) string {
|
||||||
|
t.Helper()
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(payload)
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeCronJob(namespace, name string) *batchv1.CronJob {
|
||||||
|
return &batchv1.CronJob{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
|
||||||
|
Spec: batchv1.CronJobSpec{
|
||||||
|
JobTemplate: batchv1.JobTemplateSpec{
|
||||||
|
Spec: batchv1.JobSpec{
|
||||||
|
Template: corev1.PodTemplateSpec{
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{Name: "sync", Image: "alpine/git:v2.47.2"},
|
||||||
|
},
|
||||||
|
RestartPolicy: corev1.RestartPolicyNever,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- #
|
||||||
|
// VerifySignature
|
||||||
|
// --------------------------------------------------------------------------- #
|
||||||
|
func TestVerifySignature_AcceptsCorrectHMAC(t *testing.T) {
|
||||||
|
payload := []byte(`{"ref":"refs/heads/main"}`)
|
||||||
|
secret := "s3cret"
|
||||||
|
sig := sign(t, payload, secret)
|
||||||
|
assert.True(t, VerifySignature(payload, sig, secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySignature_RejectsWrongSecret(t *testing.T) {
|
||||||
|
payload := []byte(`{"ref":"refs/heads/main"}`)
|
||||||
|
sig := sign(t, payload, "right-secret")
|
||||||
|
assert.False(t, VerifySignature(payload, sig, "wrong-secret"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySignature_RejectsTamperedPayload(t *testing.T) {
|
||||||
|
secret := "s3cret"
|
||||||
|
sig := sign(t, []byte(`{"ref":"refs/heads/main"}`), secret)
|
||||||
|
assert.False(t, VerifySignature([]byte(`{"ref":"refs/heads/evil"}`), sig, secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySignature_RejectsEmptySignature(t *testing.T) {
|
||||||
|
assert.False(t, VerifySignature([]byte("payload"), "", "secret"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- #
|
||||||
|
// TriggerJobFromCronJob
|
||||||
|
// --------------------------------------------------------------------------- #
|
||||||
|
func TestTriggerJobFromCronJob_CreatesJobMatchingTemplate(t *testing.T) {
|
||||||
|
cs := newFakeClientset(fakeCronJob("brain", "brain-sync"))
|
||||||
|
|
||||||
|
jobName, err := TriggerJobFromCronJob(context.Background(), cs, "brain", "brain-sync")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, jobName)
|
||||||
|
|
||||||
|
jobs, err := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, jobs.Items, 1)
|
||||||
|
assert.Equal(t, "alpine/git:v2.47.2", jobs.Items[0].Spec.Template.Spec.Containers[0].Image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTriggerJobFromCronJob_ErrorsWhenCronJobMissing(t *testing.T) {
|
||||||
|
cs := fake.NewSimpleClientset()
|
||||||
|
_, err := TriggerJobFromCronJob(context.Background(), cs, "brain", "brain-sync")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- #
|
||||||
|
// Handler.ServeHTTP
|
||||||
|
// --------------------------------------------------------------------------- #
|
||||||
|
func newTestHandler(cs *fake.Clientset) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
Secret: "s3cret",
|
||||||
|
Clientset: cs,
|
||||||
|
Namespace: "brain",
|
||||||
|
CronJobName: "brain-sync",
|
||||||
|
WatchRepo: "mathias/brain",
|
||||||
|
Logger: testLogger(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushPayload(t *testing.T, repo, ref string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
body := map[string]any{
|
||||||
|
"ref": ref,
|
||||||
|
"repository": map[string]any{
|
||||||
|
"full_name": repo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_RejectsMissingSignature(t *testing.T) {
|
||||||
|
cs := fake.NewSimpleClientset(fakeCronJob("brain", "brain-sync"))
|
||||||
|
h := newTestHandler(cs)
|
||||||
|
payload := pushPayload(t, "mathias/brain", "refs/heads/main")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/brain-sync", bytes.NewReader(payload))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||||
|
jobs, _ := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
||||||
|
assert.Empty(t, jobs.Items, "must not trigger a job on an unsigned request")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_RejectsWrongSignature(t *testing.T) {
|
||||||
|
cs := fake.NewSimpleClientset(fakeCronJob("brain", "brain-sync"))
|
||||||
|
h := newTestHandler(cs)
|
||||||
|
payload := pushPayload(t, "mathias/brain", "refs/heads/main")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/brain-sync", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("X-Gitea-Signature", sign(t, payload, "not-the-real-secret"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||||
|
jobs, _ := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
||||||
|
assert.Empty(t, jobs.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_IgnoresOtherRepos(t *testing.T) {
|
||||||
|
cs := fake.NewSimpleClientset(fakeCronJob("brain", "brain-sync"))
|
||||||
|
h := newTestHandler(cs)
|
||||||
|
payload := pushPayload(t, "mathias/some-other-repo", "refs/heads/main")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/brain-sync", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("X-Gitea-Signature", sign(t, payload, "s3cret"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
jobs, _ := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
||||||
|
assert.Empty(t, jobs.Items, "must not trigger for a push to an unrelated repo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_IgnoresNonMainBranch(t *testing.T) {
|
||||||
|
cs := fake.NewSimpleClientset(fakeCronJob("brain", "brain-sync"))
|
||||||
|
h := newTestHandler(cs)
|
||||||
|
payload := pushPayload(t, "mathias/brain", "refs/heads/some-feature-branch")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/brain-sync", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("X-Gitea-Signature", sign(t, payload, "s3cret"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
jobs, _ := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
||||||
|
assert.Empty(t, jobs.Items, "must not trigger for a push to a non-main branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_TriggersJobOnValidMainPush(t *testing.T) {
|
||||||
|
cs := fake.NewSimpleClientset(fakeCronJob("brain", "brain-sync"))
|
||||||
|
h := newTestHandler(cs)
|
||||||
|
payload := pushPayload(t, "mathias/brain", "refs/heads/main")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/brain-sync", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("X-Gitea-Signature", sign(t, payload, "s3cret"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
jobs, err := cs.BatchV1().Jobs("brain").List(context.Background(), metav1.ListOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, jobs.Items, 1, "a valid push to main on the watched repo must trigger exactly one job")
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ type RoutingConfig struct {
|
|||||||
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
|
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
|
||||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||||
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
||||||
|
BrainMCPToken string // BRAIN_MCP_TOKEN, bearer for the auth-gated ingestion /mcp (session_log)
|
||||||
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
||||||
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
|
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
|
||||||
// RouteLocalFloor and RouteLocalCeil intentionally invert the usual
|
// RouteLocalFloor and RouteLocalCeil intentionally invert the usual
|
||||||
@@ -44,6 +45,7 @@ func LoadRouting() (RoutingConfig, error) {
|
|||||||
LiteLLMBaseURL: envOr("LITELLM_BASE_URL", "https://llm-api.d-ma.be"),
|
LiteLLMBaseURL: envOr("LITELLM_BASE_URL", "https://llm-api.d-ma.be"),
|
||||||
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
||||||
BrainURL: envOr("BRAIN_URL", "http://ingestion.supervisor:3300"),
|
BrainURL: envOr("BRAIN_URL", "http://ingestion.supervisor:3300"),
|
||||||
|
BrainMCPToken: os.Getenv("BRAIN_MCP_TOKEN"),
|
||||||
FastModel: envOr("HYPERGUILD_FAST_MODEL", "koala/qwen35-9b-fast"),
|
FastModel: envOr("HYPERGUILD_FAST_MODEL", "koala/qwen35-9b-fast"),
|
||||||
ThinkingModel: envOr("HYPERGUILD_THINKING_MODEL", "iguana/gemma4-26b"),
|
ThinkingModel: envOr("HYPERGUILD_THINKING_MODEL", "iguana/gemma4-26b"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package routing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/binary"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CanonicalHash returns a deterministic 64-bit hash of (system, user).
|
|
||||||
// Used to make sample-band routing decisions reproducible: identical input
|
|
||||||
// strings produce the same hash on every call, independent of process state.
|
|
||||||
//
|
|
||||||
// Inputs are joined with a 0x00 byte separator before hashing — distinguishes
|
|
||||||
// (system="ab", user="cd") from (system="abcd", user="").
|
|
||||||
func CanonicalHash(system, user string) uint64 {
|
|
||||||
h := sha256.New()
|
|
||||||
h.Write([]byte(system))
|
|
||||||
h.Write([]byte{0})
|
|
||||||
h.Write([]byte(user))
|
|
||||||
sum := h.Sum(nil)
|
|
||||||
return binary.BigEndian.Uint64(sum[:8])
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package routing_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/routing"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCanonicalHashDeterministic(t *testing.T) {
|
|
||||||
a := routing.CanonicalHash("system one", "user one")
|
|
||||||
b := routing.CanonicalHash("system one", "user one")
|
|
||||||
assert.Equal(t, a, b, "same inputs must produce same hash")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanonicalHashDistinguishesInputs(t *testing.T) {
|
|
||||||
cases := [][2]string{
|
|
||||||
{"sys", "user"},
|
|
||||||
{"sys", "user2"},
|
|
||||||
{"sys2", "user"},
|
|
||||||
{"", "system\x00user"}, // separator collision attempt
|
|
||||||
{"system\x00user", ""},
|
|
||||||
}
|
|
||||||
seen := make(map[uint64]bool)
|
|
||||||
for _, c := range cases {
|
|
||||||
h := routing.CanonicalHash(c[0], c[1])
|
|
||||||
assert.False(t, seen[h], "collision on %v", c)
|
|
||||||
seen[h] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanonicalHashLowBitDistribution(t *testing.T) {
|
|
||||||
// Sanity check: across 1000 distinct inputs, low-bit split is roughly even.
|
|
||||||
zeros, ones := 0, 0
|
|
||||||
for i := 0; i < 1000; i++ {
|
|
||||||
h := routing.CanonicalHash("sys", string(rune('a'+(i%26)))+string(rune(i)))
|
|
||||||
if h&1 == 0 {
|
|
||||||
zeros++
|
|
||||||
} else {
|
|
||||||
ones++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Allow ±15% deviation from 500/500. Tighter would be flaky on real data.
|
|
||||||
assert.InDelta(t, 500, zeros, 150)
|
|
||||||
assert.InDelta(t, 500, ones, 150)
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package routing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LogEntry describes a single routing decision to log via the brain MCP.
|
|
||||||
type LogEntry struct {
|
|
||||||
SessionID string
|
|
||||||
Skill string // the original skill the call routed (e.g., "review")
|
|
||||||
Decision string // "local" or "thinking" or "thinking_fallback"
|
|
||||||
Message string // free-form, e.g. "model=qwen35, pass_rate=0.94"
|
|
||||||
ProjectRoot string
|
|
||||||
DurationMs int64
|
|
||||||
Failed bool // true → final_status: "fail"; false → "skip"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logger posts session_log entries to a brain MCP at BrainURL + /mcp.
|
|
||||||
type Logger struct {
|
|
||||||
BrainURL string
|
|
||||||
HTTP *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLogger creates a Logger with a 2-second HTTP timeout.
|
|
||||||
func NewLogger(brainURL string) *Logger {
|
|
||||||
return &Logger{
|
|
||||||
BrainURL: brainURL,
|
|
||||||
HTTP: &http.Client{Timeout: 2 * time.Second},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogDecision posts a session_log MCP call. Errors are returned but the caller
|
|
||||||
// MUST NOT block real work on them — logging is best-effort.
|
|
||||||
func (l *Logger) LogDecision(ctx context.Context, e LogEntry) error {
|
|
||||||
status := "skip"
|
|
||||||
if e.Failed {
|
|
||||||
status = "fail"
|
|
||||||
}
|
|
||||||
payload := map[string]any{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 1,
|
|
||||||
"method": "tools/call",
|
|
||||||
"params": map[string]any{
|
|
||||||
"name": "session_log",
|
|
||||||
"arguments": map[string]any{
|
|
||||||
"session_id": e.SessionID,
|
|
||||||
"skill": "_routing",
|
|
||||||
"phase": "decide",
|
|
||||||
"final_status": status,
|
|
||||||
"message": fmt.Sprintf("%s: %s — %s", e.Skill, e.Decision, e.Message),
|
|
||||||
"duration_ms": e.DurationMs,
|
|
||||||
"project_root": e.ProjectRoot,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("log: marshal: %w", err)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.BrainURL+"/mcp", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("log: build request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
resp, err := l.HTTP.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("log: request: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("log: server returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package routing_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/routing"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoggerLogDecision(t *testing.T) {
|
|
||||||
var captured map[string]any
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
|
||||||
assert.Equal(t, "/mcp", r.URL.Path)
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
require.NoError(t, json.Unmarshal(body, &captured))
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": "ok"}}}})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
l := routing.NewLogger(srv.URL)
|
|
||||||
err := l.LogDecision(context.Background(), routing.LogEntry{
|
|
||||||
SessionID: "sess-1",
|
|
||||||
Skill: "review",
|
|
||||||
Decision: "local",
|
|
||||||
Message: "model=qwen35, pass_rate=0.94",
|
|
||||||
ProjectRoot: "/home/x/proj",
|
|
||||||
DurationMs: 1234,
|
|
||||||
Failed: false,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
params := captured["params"].(map[string]any)
|
|
||||||
assert.Equal(t, "tools/call", captured["method"])
|
|
||||||
assert.Equal(t, "session_log", params["name"])
|
|
||||||
|
|
||||||
args := params["arguments"].(map[string]any)
|
|
||||||
assert.Equal(t, "_routing", args["skill"])
|
|
||||||
assert.Equal(t, "decide", args["phase"])
|
|
||||||
assert.Equal(t, "skip", args["final_status"])
|
|
||||||
assert.Contains(t, args["message"].(string), "review: local")
|
|
||||||
assert.Equal(t, "sess-1", args["session_id"])
|
|
||||||
assert.Equal(t, "/home/x/proj", args["project_root"])
|
|
||||||
assert.Equal(t, float64(1234), args["duration_ms"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoggerLogFailure(t *testing.T) {
|
|
||||||
var captured map[string]any
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
_ = json.Unmarshal(body, &captured)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
l := routing.NewLogger(srv.URL)
|
|
||||||
err := l.LogDecision(context.Background(), routing.LogEntry{
|
|
||||||
SessionID: "s", Skill: "debug", Decision: "local", Message: "litellm down", Failed: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
args := captured["params"].(map[string]any)["arguments"].(map[string]any)
|
|
||||||
assert.Equal(t, "fail", args["final_status"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoggerSurfacesUpstreamError(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
http.Error(w, "down", http.StatusBadGateway)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
l := routing.NewLogger(srv.URL)
|
|
||||||
err := l.LogDecision(context.Background(), routing.LogEntry{Skill: "x", SessionID: "y", Decision: "local"})
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package routing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fetcher reads /pass-rate from the brain pod with a per-skill TTL cache.
|
|
||||||
type Fetcher struct {
|
|
||||||
BaseURL string
|
|
||||||
Window string
|
|
||||||
TTL time.Duration
|
|
||||||
HTTP *http.Client
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
cache map[string]cachedRate
|
|
||||||
}
|
|
||||||
|
|
||||||
type cachedRate struct {
|
|
||||||
value *float64
|
|
||||||
at time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type passRateResponse struct {
|
|
||||||
PassRate *float64 `json:"pass_rate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFetcher returns a Fetcher that calls baseURL + /pass-rate with the
|
|
||||||
// given window string. If ttl is zero, defaults to 60 seconds. The HTTP
|
|
||||||
// client uses a 1-second total timeout.
|
|
||||||
func NewFetcher(baseURL, window string, ttl time.Duration) *Fetcher {
|
|
||||||
if ttl == 0 {
|
|
||||||
ttl = 60 * time.Second
|
|
||||||
}
|
|
||||||
return &Fetcher{
|
|
||||||
BaseURL: baseURL,
|
|
||||||
Window: window,
|
|
||||||
TTL: ttl,
|
|
||||||
HTTP: &http.Client{Timeout: time.Second},
|
|
||||||
cache: make(map[string]cachedRate),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns the pass rate for the named skill, or nil if no data exists,
|
|
||||||
// or an error if the brain is unreachable. Caches successful results.
|
|
||||||
func (f *Fetcher) Get(ctx context.Context, skill string) (*float64, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
if c, ok := f.cache[skill]; ok && time.Since(c.at) < f.TTL {
|
|
||||||
v := c.value
|
|
||||||
f.mu.Unlock()
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
f.mu.Unlock()
|
|
||||||
|
|
||||||
u := fmt.Sprintf("%s/pass-rate?skill=%s&window=%s",
|
|
||||||
f.BaseURL, url.QueryEscape(skill), url.QueryEscape(f.Window))
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("passrate: build request: %w", err)
|
|
||||||
}
|
|
||||||
resp, err := f.HTTP.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("passrate: request: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("passrate: server returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body passRateResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
||||||
return nil, fmt.Errorf("passrate: decode: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.mu.Lock()
|
|
||||||
f.cache[skill] = cachedRate{value: body.PassRate, at: time.Now()}
|
|
||||||
f.mu.Unlock()
|
|
||||||
|
|
||||||
return body.PassRate, nil
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package routing_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/routing"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFetcherGetReturnsPassRate(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
assert.Equal(t, http.MethodGet, r.Method)
|
|
||||||
assert.Equal(t, "/pass-rate", r.URL.Path)
|
|
||||||
assert.Equal(t, "tdd", r.URL.Query().Get("skill"))
|
|
||||||
assert.Equal(t, "7d", r.URL.Query().Get("window"))
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"skill": "tdd", "pass_rate": 0.94})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
f := routing.NewFetcher(srv.URL, "7d", time.Minute)
|
|
||||||
pr, err := f.Get(context.Background(), "tdd")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, pr)
|
|
||||||
assert.InDelta(t, 0.94, *pr, 1e-9)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetcherGetReturnsNilWhenNoData(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"skill": "novel", "pass_rate": nil})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
f := routing.NewFetcher(srv.URL, "7d", time.Minute)
|
|
||||||
pr, err := f.Get(context.Background(), "novel")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, pr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetcherCachesWithinTTL(t *testing.T) {
|
|
||||||
var calls int32
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
atomic.AddInt32(&calls, 1)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.5})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
f := routing.NewFetcher(srv.URL, "7d", time.Minute)
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
_, err := f.Get(context.Background(), "tdd")
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "should hit upstream once and serve four times from cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetcherFetchesAgainAfterTTLExpires(t *testing.T) {
|
|
||||||
var calls int32
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
atomic.AddInt32(&calls, 1)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.5})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
// Tight TTL so the test stays fast.
|
|
||||||
f := routing.NewFetcher(srv.URL, "7d", 5*time.Millisecond)
|
|
||||||
_, err := f.Get(context.Background(), "tdd")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int32(1), atomic.LoadInt32(&calls))
|
|
||||||
|
|
||||||
// Sleep past TTL, then a second Get should hit upstream again.
|
|
||||||
time.Sleep(15 * time.Millisecond)
|
|
||||||
_, err = f.Get(context.Background(), "tdd")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int32(2), atomic.LoadInt32(&calls), "expected fresh upstream call after TTL expiry")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetcherSurfacesUpstreamError(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
http.Error(w, "boom", http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
f := routing.NewFetcher(srv.URL, "7d", time.Minute)
|
|
||||||
pr, err := f.Get(context.Background(), "tdd")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Nil(t, pr)
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package routing
|
|
||||||
|
|
||||||
// Decision is the route picked for a single skill call.
|
|
||||||
type Decision int
|
|
||||||
|
|
||||||
const (
|
|
||||||
DecideLocal Decision = iota
|
|
||||||
DecideClaude
|
|
||||||
)
|
|
||||||
|
|
||||||
func (d Decision) String() string {
|
|
||||||
if d == DecideLocal {
|
|
||||||
return "local"
|
|
||||||
}
|
|
||||||
return "claude"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Policy holds the floor/ceil thresholds for routing decisions.
|
|
||||||
//
|
|
||||||
// Rules (in order):
|
|
||||||
//
|
|
||||||
// 1. passRate == nil → DecideLocal (default-to-local for cost-routable skills)
|
|
||||||
// 2. *passRate >= Floor → DecideLocal (trust local)
|
|
||||||
// 3. *passRate < Ceil → DecideClaude (don't trust local)
|
|
||||||
// 4. otherwise (sample band) → requestHash low bit picks: 0=local, 1=claude
|
|
||||||
type Policy struct {
|
|
||||||
Floor float64
|
|
||||||
Ceil float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decide returns the routing decision for a single call.
|
|
||||||
// requestHash is consulted only when passRate is in the sample band [Ceil, Floor).
|
|
||||||
func (p Policy) Decide(passRate *float64, requestHash uint64) Decision {
|
|
||||||
if passRate == nil {
|
|
||||||
return DecideLocal
|
|
||||||
}
|
|
||||||
if *passRate >= p.Floor {
|
|
||||||
return DecideLocal
|
|
||||||
}
|
|
||||||
if *passRate < p.Ceil {
|
|
||||||
return DecideClaude
|
|
||||||
}
|
|
||||||
if requestHash&1 == 0 {
|
|
||||||
return DecideLocal
|
|
||||||
}
|
|
||||||
return DecideClaude
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package routing_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/routing"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ptr(f float64) *float64 { return &f }
|
|
||||||
|
|
||||||
func TestPolicyDecide(t *testing.T) {
|
|
||||||
p := routing.Policy{Floor: 0.9, Ceil: 0.7}
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
passRate *float64
|
|
||||||
hash uint64
|
|
||||||
want routing.Decision
|
|
||||||
}{
|
|
||||||
{"null pass rate → local", nil, 0, routing.DecideLocal},
|
|
||||||
{"null pass rate, hash irrelevant → local", nil, 0xDEADBEEF, routing.DecideLocal},
|
|
||||||
{"at floor → local", ptr(0.9), 0, routing.DecideLocal},
|
|
||||||
{"above floor → local", ptr(0.95), 0, routing.DecideLocal},
|
|
||||||
{"below ceil → claude", ptr(0.5), 0, routing.DecideClaude},
|
|
||||||
{"at ceil → sample-band even-hash → local", ptr(0.7), 0, routing.DecideLocal},
|
|
||||||
{"sample band, even hash → local", ptr(0.8), 2, routing.DecideLocal},
|
|
||||||
{"sample band, odd hash → claude", ptr(0.8), 3, routing.DecideClaude},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tc.want, p.Decide(tc.passRate, tc.hash))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package routing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CompleteFunc matches the signature used by every skill package's Config.
|
|
||||||
type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error)
|
|
||||||
|
|
||||||
// RunInput captures the per-call inputs the dispatch wrapper needs.
|
|
||||||
type RunInput struct {
|
|
||||||
Skill string
|
|
||||||
System string
|
|
||||||
User string
|
|
||||||
SessionID string
|
|
||||||
ProjectRoot string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Router composes a pass-rate fetcher, a decision policy, a session logger,
|
|
||||||
// and a LiteLLM client. Skill packages receive Router.Run as their CompleteFunc.
|
|
||||||
type Router struct {
|
|
||||||
Fetcher *Fetcher
|
|
||||||
Logger *Logger
|
|
||||||
Policy Policy
|
|
||||||
FastModel string
|
|
||||||
ThinkingModel string
|
|
||||||
Complete CompleteFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run executes one skill call: decides local vs claude, calls LiteLLM, logs the
|
|
||||||
// decision. On local-side error, falls open by retrying once on the Claude model.
|
|
||||||
func (r *Router) Run(ctx context.Context, in RunInput) (string, int64, error) {
|
|
||||||
pr, ferr := r.Fetcher.Get(ctx, in.Skill)
|
|
||||||
if ferr != nil {
|
|
||||||
slog.Warn("router: pass-rate unreachable, defaulting to local", "skill", in.Skill, "err", ferr)
|
|
||||||
pr = nil
|
|
||||||
}
|
|
||||||
hash := CanonicalHash(in.System, in.User)
|
|
||||||
decision := r.Policy.Decide(pr, hash)
|
|
||||||
|
|
||||||
model := r.ThinkingModel
|
|
||||||
if decision == DecideLocal {
|
|
||||||
model = r.FastModel
|
|
||||||
}
|
|
||||||
|
|
||||||
out, ms, err := r.Complete(ctx, model, in.System, in.User)
|
|
||||||
if lerr := r.Logger.LogDecision(ctx, LogEntry{
|
|
||||||
SessionID: in.SessionID,
|
|
||||||
Skill: in.Skill,
|
|
||||||
Decision: decision.String(),
|
|
||||||
Message: fmt.Sprintf("model=%s, pass_rate=%s", model, formatPassRate(pr)),
|
|
||||||
ProjectRoot: in.ProjectRoot,
|
|
||||||
DurationMs: ms,
|
|
||||||
Failed: err != nil,
|
|
||||||
}); lerr != nil {
|
|
||||||
slog.Warn("router: log decision failed", "skill", in.Skill, "err", lerr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && decision == DecideLocal {
|
|
||||||
slog.Warn("router: fast failed, falling open to thinking model", "skill", in.Skill, "err", err)
|
|
||||||
out, ms, err = r.Complete(ctx, r.ThinkingModel, in.System, in.User)
|
|
||||||
if lerr := r.Logger.LogDecision(ctx, LogEntry{
|
|
||||||
SessionID: in.SessionID,
|
|
||||||
Skill: in.Skill,
|
|
||||||
Decision: "thinking_fallback",
|
|
||||||
Message: fmt.Sprintf("model=%s, after-fast-error", r.ThinkingModel),
|
|
||||||
ProjectRoot: in.ProjectRoot,
|
|
||||||
DurationMs: ms,
|
|
||||||
Failed: err != nil,
|
|
||||||
}); lerr != nil {
|
|
||||||
slog.Warn("router: log decision failed", "skill", in.Skill, "err", lerr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, ms, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatPassRate(pr *float64) string {
|
|
||||||
if pr == nil {
|
|
||||||
return "null"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.2f", *pr)
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package routing_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/routing"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeLLM struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
calls []struct{ Model, System, User string }
|
|
||||||
resp string
|
|
||||||
err error
|
|
||||||
errOn string // if non-empty, only the named model errors
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeLLM) Complete(_ context.Context, model, system, user string) (string, int64, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
f.calls = append(f.calls, struct{ Model, System, User string }{model, system, user})
|
|
||||||
if f.errOn == model {
|
|
||||||
return "", 0, f.err
|
|
||||||
}
|
|
||||||
if f.err != nil && f.errOn == "" {
|
|
||||||
return "", 0, f.err
|
|
||||||
}
|
|
||||||
return f.resp, 100, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRouter(t *testing.T, llm *fakeLLM, passRate float64) (*routing.Router, *httptest.Server, *httptest.Server) {
|
|
||||||
t.Helper()
|
|
||||||
brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/pass-rate":
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": passRate})
|
|
||||||
case "/mcp":
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}})
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
t.Cleanup(brain.Close)
|
|
||||||
|
|
||||||
r := &routing.Router{
|
|
||||||
Fetcher: routing.NewFetcher(brain.URL, "7d", time.Minute),
|
|
||||||
Logger: routing.NewLogger(brain.URL),
|
|
||||||
Policy: routing.Policy{Floor: 0.9, Ceil: 0.7},
|
|
||||||
FastModel: "koala/qwen35-9b-fast",
|
|
||||||
ThinkingModel: "iguana/gemma4-26b",
|
|
||||||
Complete: llm.Complete,
|
|
||||||
}
|
|
||||||
return r, brain, brain
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterRoutesLocalAtHighPassRate(t *testing.T) {
|
|
||||||
llm := &fakeLLM{resp: "ok"}
|
|
||||||
r, _, _ := newRouter(t, llm, 0.95)
|
|
||||||
|
|
||||||
out, _, err := r.Run(context.Background(), routing.RunInput{
|
|
||||||
Skill: "review", System: "sys", User: "user", SessionID: "s1", ProjectRoot: "/p",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "ok", out)
|
|
||||||
|
|
||||||
llm.mu.Lock()
|
|
||||||
defer llm.mu.Unlock()
|
|
||||||
require.Len(t, llm.calls, 1)
|
|
||||||
assert.Equal(t, "koala/qwen35-9b-fast", llm.calls[0].Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterRoutesThinkingAtLowPassRate(t *testing.T) {
|
|
||||||
llm := &fakeLLM{resp: "ok"}
|
|
||||||
r, _, _ := newRouter(t, llm, 0.3)
|
|
||||||
|
|
||||||
_, _, err := r.Run(context.Background(), routing.RunInput{
|
|
||||||
Skill: "review", System: "sys", User: "user", SessionID: "s2",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
llm.mu.Lock()
|
|
||||||
defer llm.mu.Unlock()
|
|
||||||
require.Len(t, llm.calls, 1)
|
|
||||||
assert.Equal(t, "iguana/gemma4-26b", llm.calls[0].Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterFailsOpenFastErrorToThinking(t *testing.T) {
|
|
||||||
llm := &fakeLLM{resp: "ok-after-fallback", err: errors.New("fast boom"), errOn: "koala/qwen35-9b-fast"}
|
|
||||||
r, _, _ := newRouter(t, llm, 0.95) // would route fast
|
|
||||||
|
|
||||||
out, _, err := r.Run(context.Background(), routing.RunInput{
|
|
||||||
Skill: "review", System: "sys", User: "user", SessionID: "s3",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "ok-after-fallback", out)
|
|
||||||
|
|
||||||
llm.mu.Lock()
|
|
||||||
defer llm.mu.Unlock()
|
|
||||||
require.Len(t, llm.calls, 2)
|
|
||||||
assert.Equal(t, "koala/qwen35-9b-fast", llm.calls[0].Model)
|
|
||||||
assert.Equal(t, "iguana/gemma4-26b", llm.calls[1].Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterDefaultsToFastWhenBrainUnreachable(t *testing.T) {
|
|
||||||
// Brain returns 500 → fetcher errors → router treats pass rate as nil → fast.
|
|
||||||
brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
http.Error(w, "down", http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
defer brain.Close()
|
|
||||||
|
|
||||||
llm := &fakeLLM{resp: "ok"}
|
|
||||||
r := &routing.Router{
|
|
||||||
Fetcher: routing.NewFetcher(brain.URL, "7d", time.Minute),
|
|
||||||
Logger: routing.NewLogger(brain.URL),
|
|
||||||
Policy: routing.Policy{Floor: 0.9, Ceil: 0.7},
|
|
||||||
FastModel: "koala/qwen35-9b-fast",
|
|
||||||
ThinkingModel: "iguana/gemma4-26b",
|
|
||||||
Complete: llm.Complete,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, err := r.Run(context.Background(), routing.RunInput{
|
|
||||||
Skill: "review", System: "sys", User: "user", SessionID: "s4",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
llm.mu.Lock()
|
|
||||||
defer llm.mu.Unlock()
|
|
||||||
require.Len(t, llm.calls, 1)
|
|
||||||
assert.Equal(t, "koala/qwen35-9b-fast", llm.calls[0].Model)
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package routing_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/debug"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/review"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/trainer"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestToolsListMatchesSupervisorSnapshot pins the four routed skills' tool
|
|
||||||
// definitions to the supervisor's current advertisement. A deliberate schema
|
|
||||||
// change must be reflected here by updating testdata/tools_list.snapshot.json.
|
|
||||||
func TestToolsListMatchesSupervisorSnapshot(t *testing.T) {
|
|
||||||
complete := func(_ context.Context, _, _, _ string) (string, int64, error) {
|
|
||||||
return "", 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := registry.New()
|
|
||||||
reg.Register(review.New(review.Config{
|
|
||||||
SkillPrompt: "stub",
|
|
||||||
DefaultModel: "stub",
|
|
||||||
CompleteFunc: complete,
|
|
||||||
}))
|
|
||||||
reg.Register(debug.New(debug.Config{
|
|
||||||
SkillPrompt: "stub",
|
|
||||||
DefaultModel: "stub",
|
|
||||||
CompleteFunc: complete,
|
|
||||||
}))
|
|
||||||
reg.Register(retrospective.New(retrospective.Config{
|
|
||||||
SkillPrompt: "stub",
|
|
||||||
DefaultModel: "stub",
|
|
||||||
CompleteFunc: complete,
|
|
||||||
}))
|
|
||||||
reg.Register(trainer.New(trainer.Config{
|
|
||||||
ReaderPrompt: "stub",
|
|
||||||
WriterPrompt: "stub",
|
|
||||||
DefaultModel: "stub",
|
|
||||||
CompleteFunc: complete,
|
|
||||||
}))
|
|
||||||
|
|
||||||
wanted := map[string]bool{
|
|
||||||
"review": true,
|
|
||||||
"debug": true,
|
|
||||||
"retrospective": true,
|
|
||||||
"trainer": true,
|
|
||||||
}
|
|
||||||
var routed []registry.ToolDef
|
|
||||||
for _, td := range reg.Tools() {
|
|
||||||
if wanted[td.Name] {
|
|
||||||
routed = append(routed, td)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(routed, func(i, j int) bool { return routed[i].Name < routed[j].Name })
|
|
||||||
|
|
||||||
got, err := json.MarshalIndent(routed, "", " ")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
want, err := os.ReadFile("testdata/tools_list.snapshot.json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Normalize both via re-encode so whitespace differences don't dominate.
|
|
||||||
var gotV, wantV any
|
|
||||||
require.NoError(t, json.Unmarshal(got, &gotV))
|
|
||||||
require.NoError(t, json.Unmarshal(want, &wantV))
|
|
||||||
|
|
||||||
gotN, _ := json.MarshalIndent(gotV, "", " ")
|
|
||||||
wantN, _ := json.MarshalIndent(wantV, "", " ")
|
|
||||||
|
|
||||||
assert.Equal(t, string(wantN), string(gotN),
|
|
||||||
"tool advertisement drifted from supervisor snapshot — update testdata/tools_list.snapshot.json deliberately if the schema change is intentional")
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"name": "debug",
|
|
||||||
"description": "Consult a local model to analyse an error and return hypotheses ordered by likelihood, each with a concrete verification step.",
|
|
||||||
"inputSchema": {
|
|
||||||
"properties": {
|
|
||||||
"context": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"project_root": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"session_id": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"project_root",
|
|
||||||
"error"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "retrospective",
|
|
||||||
"description": "Consult a local model to analyse a completed session and identify what is novel or worth preserving as organizational knowledge.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"session_id": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "review",
|
|
||||||
"description": "Consult a local model for a structured code review of the specified files. Returns findings with severity levels.",
|
|
||||||
"inputSchema": {
|
|
||||||
"properties": {
|
|
||||||
"context": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"project_root": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"session_id": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"project_root",
|
|
||||||
"files"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "trainer",
|
|
||||||
"description": "Consult a local model to identify learning moments from a session log and suggest knowledge to preserve in the brain.",
|
|
||||||
"inputSchema": {
|
|
||||||
"properties": {
|
|
||||||
"model": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"session_id": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// internal/skills/debug/handlers.go
|
|
||||||
package debug
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/brain"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
type debugArgs struct {
|
|
||||||
ProjectRoot string `json:"project_root"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
Context string `json:"context"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
SessionID string `json:"session_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle dispatches the MCP tool call to the appropriate handler.
|
|
||||||
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
||||||
if tool != "debug" {
|
|
||||||
return nil, fmt.Errorf("unknown tool: %s", tool)
|
|
||||||
}
|
|
||||||
var a debugArgs
|
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
|
||||||
}
|
|
||||||
if a.ProjectRoot == "" {
|
|
||||||
return nil, fmt.Errorf("project_root is required")
|
|
||||||
}
|
|
||||||
if a.Error == "" {
|
|
||||||
return nil, fmt.Errorf("error is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
model := a.Model
|
|
||||||
if model == "" {
|
|
||||||
model = s.cfg.DefaultModel
|
|
||||||
}
|
|
||||||
|
|
||||||
brainCtx, _ := brain.Query(ctx, s.cfg.IngestBaseURL, a.Error+" "+a.Context, 3)
|
|
||||||
|
|
||||||
task := fmt.Sprintf(
|
|
||||||
"phase: debug\nproject_root: %s\nerror: %s\ncontext: %s\nmodel: %s",
|
|
||||||
a.ProjectRoot, a.Error, a.Context, model,
|
|
||||||
)
|
|
||||||
task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "debug", task)
|
|
||||||
if brainCtx != "" {
|
|
||||||
task = brainCtx + "\n---\n\n" + task
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.cfg.CompleteFunc == nil {
|
|
||||||
return nil, fmt.Errorf("no executor configured")
|
|
||||||
}
|
|
||||||
t0 := time.Now()
|
|
||||||
text, dur, err := s.cfg.CompleteFunc(ctx, model, s.cfg.SkillPrompt, task)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.SessionID != "" && s.cfg.SessionsDir != "" {
|
|
||||||
msg := text
|
|
||||||
if len(msg) > 200 {
|
|
||||||
msg = msg[:200]
|
|
||||||
}
|
|
||||||
_ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{
|
|
||||||
SessionID: a.SessionID,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Skill: "debug",
|
|
||||||
Phase: "debug",
|
|
||||||
ProjectRoot: a.ProjectRoot,
|
|
||||||
FinalStatus: "ok",
|
|
||||||
ModelUsed: model,
|
|
||||||
DurationMs: time.Since(t0).Milliseconds(),
|
|
||||||
Message: msg,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(map[string]any{"text": text, "model": model, "duration_ms": dur})
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// internal/skills/debug/handlers_test.go
|
|
||||||
package debug_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/debug"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDebugToolRegistered(t *testing.T) {
|
|
||||||
sk := debug.New(debug.Config{SkillPrompt: "debug rules"})
|
|
||||||
names := make([]string, 0)
|
|
||||||
for _, tool := range sk.Tools() {
|
|
||||||
names = append(names, tool.Name)
|
|
||||||
}
|
|
||||||
assert.Contains(t, names, "debug")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDebugRequiresProjectRoot(t *testing.T) {
|
|
||||||
sk := debug.New(debug.Config{SkillPrompt: "d"})
|
|
||||||
_, err := sk.Handle(context.Background(), "debug", json.RawMessage(`{"error":"panic: nil pointer"}`))
|
|
||||||
assert.ErrorContains(t, err, "project_root")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDebugRequiresError(t *testing.T) {
|
|
||||||
sk := debug.New(debug.Config{SkillPrompt: "d"})
|
|
||||||
_, err := sk.Handle(context.Background(), "debug", json.RawMessage(`{"project_root":"/tmp"}`))
|
|
||||||
assert.ErrorContains(t, err, "error")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDebugCallsCompleteFunc(t *testing.T) {
|
|
||||||
var capturedTask string
|
|
||||||
fakeFn := func(_ context.Context, _, _, user string) (string, int64, error) {
|
|
||||||
capturedTask = user
|
|
||||||
return "HYPOTHESIS 1 (high): nil map access. Verify: go test ./...", 90, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sk := debug.New(debug.Config{SkillPrompt: "debug rules", CompleteFunc: fakeFn, SessionsDir: t.TempDir()})
|
|
||||||
out, err := sk.Handle(context.Background(), "debug", json.RawMessage(
|
|
||||||
`{"project_root":"/tmp/proj","error":"panic: nil pointer dereference at foo.go:42","context":"occurs on startup"}`,
|
|
||||||
))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, capturedTask, "panic: nil pointer dereference")
|
|
||||||
assert.Contains(t, capturedTask, "occurs on startup")
|
|
||||||
|
|
||||||
var result map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &result))
|
|
||||||
assert.Contains(t, result["text"], "nil map access")
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
// internal/skills/debug/skill.go
|
|
||||||
package debug
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CompleteFunc is the function used to call a local model.
|
|
||||||
type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error)
|
|
||||||
|
|
||||||
// Config holds dependencies for the debug skill.
|
|
||||||
type Config struct {
|
|
||||||
SkillPrompt string
|
|
||||||
DefaultModel string
|
|
||||||
CompleteFunc CompleteFunc
|
|
||||||
SessionsDir string
|
|
||||||
IngestBaseURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skill implements the debug MCP tool.
|
|
||||||
type Skill struct{ cfg Config }
|
|
||||||
|
|
||||||
// New creates a new debug Skill.
|
|
||||||
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
|
|
||||||
|
|
||||||
// Name returns the skill identifier.
|
|
||||||
func (s *Skill) Name() string { return "debug" }
|
|
||||||
|
|
||||||
// Tools returns the MCP tool definitions for this skill.
|
|
||||||
func (s *Skill) Tools() []registry.ToolDef {
|
|
||||||
schema := func(required []string, props map[string]any) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
str := map[string]any{"type": "string"}
|
|
||||||
return []registry.ToolDef{
|
|
||||||
{
|
|
||||||
Name: "debug",
|
|
||||||
Description: "Consult a local model to analyse an error and return hypotheses ordered by likelihood, each with a concrete verification step.",
|
|
||||||
InputSchema: schema(
|
|
||||||
[]string{"project_root", "error"},
|
|
||||||
map[string]any{
|
|
||||||
"project_root": str,
|
|
||||||
"error": str,
|
|
||||||
"context": str,
|
|
||||||
"model": str,
|
|
||||||
"session_id": str,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
package project
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/githubclient"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
type createArgs struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Hypothesis string `json:"hypothesis"`
|
|
||||||
Folder string `json:"folder"`
|
|
||||||
Stack string `json:"stack"`
|
|
||||||
Private bool `json:"private"`
|
|
||||||
MirrorToGitHub bool `json:"mirror_to_github,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type createResult struct {
|
|
||||||
GiteaURL string `json:"gitea_url"`
|
|
||||||
GitHubURL string `json:"github_url"`
|
|
||||||
IssueURL string `json:"issue_url"`
|
|
||||||
NextSteps string `json:"next_steps"`
|
|
||||||
|
|
||||||
// Reached records the steps that completed. Populated on partial failure
|
|
||||||
// so callers can resume manually instead of guessing what already ran.
|
|
||||||
Reached []string `json:"reached,omitempty"`
|
|
||||||
|
|
||||||
// FailedStep is non-empty when a downstream gitea-mcp call returned an
|
|
||||||
// error; the error itself is surfaced via the JSON-RPC error response,
|
|
||||||
// this field tells the operator which step it happened in.
|
|
||||||
FailedStep string `json:"failed_step,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func errUnknownTool(name string) error { return fmt.Errorf("unknown tool: %s", name) }
|
|
||||||
|
|
||||||
// step names — must match what we surface in failed_step / reached.
|
|
||||||
const (
|
|
||||||
stepCreateRepo = "create_repo"
|
|
||||||
stepCreateGitHub = "create_github_repo"
|
|
||||||
stepMirror = "mirror"
|
|
||||||
stepInfraCommit = "infra_commit"
|
|
||||||
stepIssue = "issue"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
|
||||||
var args createArgs
|
|
||||||
if err := json.Unmarshal(raw, &args); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
|
||||||
}
|
|
||||||
if err := validate(args); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl := templateFor(args.Stack)
|
|
||||||
giteaURL := fmt.Sprintf("http://gitea.d-ma.be/%s/%s", s.cfg.GiteaOwner, args.Name)
|
|
||||||
|
|
||||||
res := createResult{
|
|
||||||
GiteaURL: giteaURL,
|
|
||||||
}
|
|
||||||
if args.MirrorToGitHub {
|
|
||||||
res.GitHubURL = fmt.Sprintf("https://github.com/%s/%s", s.cfg.GitHubOwner, args.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: create_project_from_template. If the repo already exists,
|
|
||||||
// gitea-mcp returns -32003 Conflict; we treat that as idempotent success
|
|
||||||
// and continue to the next steps so re-running self-heals partial runs.
|
|
||||||
existed, err := s.callCreateRepo(ctx, args, tmpl)
|
|
||||||
if err != nil {
|
|
||||||
return marshalPartial(res, stepCreateRepo, err)
|
|
||||||
}
|
|
||||||
res.Reached = append(res.Reached, stepCreateRepo)
|
|
||||||
|
|
||||||
// Steps 2+3 are skipped when MirrorToGitHub is false. Default per
|
|
||||||
// infra ADR (Gitea as true master, GitHub as optional opt-in): keep
|
|
||||||
// client / business-logic / personal repos Gitea-only. Set
|
|
||||||
// `mirror_to_github: true` for open-source projects that want a
|
|
||||||
// public GitHub mirror (hyperguild, gitea-mcp, template-*).
|
|
||||||
if args.MirrorToGitHub {
|
|
||||||
// Step 2: create empty GitHub repo. Gitea's push-mirror cannot push
|
|
||||||
// to a non-existent remote, so the destination must exist before
|
|
||||||
// step 3 configures the mirror. Skipped when GitHub client is unset
|
|
||||||
// (degraded mode — see Config.GitHub doc).
|
|
||||||
if s.cfg.GitHub != nil {
|
|
||||||
if err := s.callCreateGitHubRepo(ctx, args); err != nil && !errors.Is(err, githubclient.ErrAlreadyExists) {
|
|
||||||
return marshalPartial(res, stepCreateGitHub, err)
|
|
||||||
}
|
|
||||||
res.Reached = append(res.Reached, stepCreateGitHub)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: configure push mirror to GitHub. Idempotent: if a mirror with
|
|
||||||
// the same remote already exists, gitea-mcp returns Conflict; we swallow it.
|
|
||||||
if err := s.callMirror(ctx, args.Name); err != nil {
|
|
||||||
if !isConflict(err) {
|
|
||||||
return marshalPartial(res, stepMirror, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.Reached = append(res.Reached, stepMirror)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: commit staging namespace manifest to infra repo. Done before
|
|
||||||
// the issue so the staging env is reconciling by the time the issue lands.
|
|
||||||
if err := s.callInfraCommit(ctx, args.Name); err != nil {
|
|
||||||
if !isConflict(err) {
|
|
||||||
return marshalPartial(res, stepInfraCommit, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.Reached = append(res.Reached, stepInfraCommit)
|
|
||||||
|
|
||||||
// Step 4: open the experiment-brief issue on the new repo.
|
|
||||||
issueURL, err := s.callIssue(ctx, args, existed)
|
|
||||||
if err != nil {
|
|
||||||
return marshalPartial(res, stepIssue, err)
|
|
||||||
}
|
|
||||||
res.IssueURL = issueURL
|
|
||||||
res.Reached = append(res.Reached, stepIssue)
|
|
||||||
|
|
||||||
folder := args.Folder
|
|
||||||
if folder == "" {
|
|
||||||
folder = "."
|
|
||||||
}
|
|
||||||
res.NextSteps = fmt.Sprintf(
|
|
||||||
"cd ~/dev/%s/%s && task new-project -- %s personal %s %s && git remote add origin http://gitea.d-ma.be/%s/%s.git && git push -u origin main",
|
|
||||||
folder, args.Name, args.Name, folder, args.Stack, s.cfg.GiteaOwner, args.Name,
|
|
||||||
)
|
|
||||||
|
|
||||||
return marshalResult(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
// callCreateRepo invokes create_project_from_template. Returns (existed, err)
|
|
||||||
// where existed=true means the destination was already present and we should
|
|
||||||
// treat it as a no-op success (idempotency).
|
|
||||||
func (s *Skill) callCreateRepo(ctx context.Context, args createArgs, template string) (bool, error) {
|
|
||||||
var out struct {
|
|
||||||
HTMLURL string `json:"html_url"`
|
|
||||||
}
|
|
||||||
err := s.cfg.Client.CallTool(ctx, "create_project_from_template", map[string]any{
|
|
||||||
"owner": s.cfg.GiteaOwner,
|
|
||||||
"name": args.Name,
|
|
||||||
"description": args.Description,
|
|
||||||
"private": args.Private,
|
|
||||||
"template_name": template,
|
|
||||||
}, &out)
|
|
||||||
if err == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if isConflict(err) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// callCreateGitHubRepo creates the empty destination repo on GitHub.
|
|
||||||
// auto_init=false in githubclient so first push from gitea doesn't conflict
|
|
||||||
// with an auto-generated README.
|
|
||||||
func (s *Skill) callCreateGitHubRepo(ctx context.Context, args createArgs) error {
|
|
||||||
_, err := s.cfg.GitHub.CreateRepo(ctx, args.Name, args.Description, args.Private)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// callMirror configures the push mirror to GitHub.
|
|
||||||
func (s *Skill) callMirror(ctx context.Context, name string) error {
|
|
||||||
remote := fmt.Sprintf("https://github.com/%s/%s.git", s.cfg.GitHubOwner, name)
|
|
||||||
return s.cfg.Client.CallTool(ctx, "repo_mirror_push", map[string]any{
|
|
||||||
"owner": s.cfg.GiteaOwner,
|
|
||||||
"name": name,
|
|
||||||
"action": "add",
|
|
||||||
"remote_address": remote,
|
|
||||||
"remote_username": s.cfg.GitHubOwner,
|
|
||||||
"remote_password": s.cfg.GitHubPAT,
|
|
||||||
"interval": "8h0m0s",
|
|
||||||
"sync_on_commit": true,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// callInfraCommit writes the staging namespace manifest directly to infra
|
|
||||||
// main. Flux reconciles within ~60s. See DECISIONS.md 2026-05-18.
|
|
||||||
func (s *Skill) callInfraCommit(ctx context.Context, name string) error {
|
|
||||||
manifest := stagingNamespaceManifest(name, time.Now().UTC().Format(time.RFC3339))
|
|
||||||
return s.cfg.Client.CallTool(ctx, "file_write_branch", map[string]any{
|
|
||||||
"owner": s.cfg.GiteaOwner,
|
|
||||||
"name": s.cfg.InfraRepo,
|
|
||||||
"path": fmt.Sprintf("k3s/staging/%s/namespace.yaml", name),
|
|
||||||
"content": manifest,
|
|
||||||
"branch": "main",
|
|
||||||
"message": fmt.Sprintf("feat(staging): add namespace for %s\n\nGenerated by hyperguild project_create.", name),
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// callIssue opens the experiment-brief issue on the newly-created repo.
|
|
||||||
// existed=true (repo pre-existed) still posts a new brief — repeated runs
|
|
||||||
// can intentionally restate intent without colliding.
|
|
||||||
func (s *Skill) callIssue(ctx context.Context, args createArgs, existed bool) (string, error) {
|
|
||||||
body := experimentBrief(args, existed)
|
|
||||||
var out struct {
|
|
||||||
HTMLURL string `json:"html_url"`
|
|
||||||
}
|
|
||||||
err := s.cfg.Client.CallTool(ctx, "issue_create", map[string]any{
|
|
||||||
"owner": s.cfg.GiteaOwner,
|
|
||||||
"name": args.Name,
|
|
||||||
"title": "experiment brief: " + args.Description,
|
|
||||||
"body": body,
|
|
||||||
}, &out)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return out.HTMLURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func stagingNamespaceManifest(name, createdAt string) string {
|
|
||||||
return fmt.Sprintf(`apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: staging-%s
|
|
||||||
labels:
|
|
||||||
managed-by: hyperguild
|
|
||||||
project: %s
|
|
||||||
created-at: "%s"
|
|
||||||
`, name, name, createdAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func experimentBrief(args createArgs, existed bool) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("## Hypothesis\n\n")
|
|
||||||
b.WriteString(args.Hypothesis)
|
|
||||||
b.WriteString("\n\n## Description\n\n")
|
|
||||||
b.WriteString(args.Description)
|
|
||||||
b.WriteString("\n\n## Stack\n\n`")
|
|
||||||
b.WriteString(args.Stack)
|
|
||||||
b.WriteString("`\n\n## Provisioning\n\n")
|
|
||||||
b.WriteString("- Repo created from `template-")
|
|
||||||
b.WriteString(args.Stack)
|
|
||||||
b.WriteString("` on Gitea.\n")
|
|
||||||
if args.MirrorToGitHub {
|
|
||||||
b.WriteString("- Push-mirror configured to GitHub.\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString("- Gitea-only (no GitHub mirror — set `mirror_to_github: true` to opt in).\n")
|
|
||||||
}
|
|
||||||
b.WriteString("- Staging namespace manifest committed to infra repo.\n\n")
|
|
||||||
if existed {
|
|
||||||
b.WriteString("> Note: this repo already existed when `project_create` ran — provisioning steps were re-applied idempotently.\n")
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func validate(args createArgs) error {
|
|
||||||
if args.Name == "" {
|
|
||||||
return errors.New("name is required")
|
|
||||||
}
|
|
||||||
if args.Description == "" {
|
|
||||||
return errors.New("description is required")
|
|
||||||
}
|
|
||||||
if args.Hypothesis == "" {
|
|
||||||
return errors.New("hypothesis is required")
|
|
||||||
}
|
|
||||||
if args.Stack != "go-agent" && args.Stack != "go-web" {
|
|
||||||
return fmt.Errorf("stack must be go-agent or go-web, got %q", args.Stack)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func templateFor(stack string) string {
|
|
||||||
switch stack {
|
|
||||||
case "go-agent":
|
|
||||||
return "template-go-agent"
|
|
||||||
default:
|
|
||||||
return "template-go-web"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isConflict(err error) bool {
|
|
||||||
var me *mcpclient.Error
|
|
||||||
if errors.As(err, &me) && me.Code == -32003 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func marshalResult(r createResult) (json.RawMessage, error) {
|
|
||||||
b, err := json.Marshal(r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("marshal result: %w", err)
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func marshalPartial(r createResult, step string, inner error) (json.RawMessage, error) {
|
|
||||||
r.FailedStep = step
|
|
||||||
b, _ := json.Marshal(r)
|
|
||||||
return b, fmt.Errorf("project_create step %q failed: %w", step, inner)
|
|
||||||
}
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
package project_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/githubclient"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/project"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeGitHub captures POST /user/repos calls.
|
|
||||||
type fakeGitHub struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
Calls []map[string]any
|
|
||||||
ReturnError int // 0 = 201 Created, 422 = already exists, etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *fakeGitHub) handler() http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var args map[string]any
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&args)
|
|
||||||
g.mu.Lock()
|
|
||||||
g.Calls = append(g.Calls, args)
|
|
||||||
code := g.ReturnError
|
|
||||||
g.mu.Unlock()
|
|
||||||
switch code {
|
|
||||||
case 0:
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(`{"full_name":"mathiasb/x","html_url":"https://github.com/mathiasb/x","clone_url":"https://github.com/mathiasb/x.git"}`))
|
|
||||||
case 422:
|
|
||||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
||||||
_, _ = w.Write([]byte(`{"errors":[{"message":"name already exists on this account"}]}`))
|
|
||||||
default:
|
|
||||||
w.WriteHeader(code)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeGiteaMCP implements just enough of the JSON-RPC tools/call surface
|
|
||||||
// to drive project_create end-to-end without an actual gitea-mcp server.
|
|
||||||
type fakeGiteaMCP struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
// Recorded calls in order.
|
|
||||||
Calls []recordedCall
|
|
||||||
// Per-tool response. Default is a generic success object.
|
|
||||||
Responses map[string]any
|
|
||||||
// Per-tool error response, takes precedence over Responses.
|
|
||||||
Errors map[string]rpcErr
|
|
||||||
}
|
|
||||||
|
|
||||||
type rpcErr struct {
|
|
||||||
Code int
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
type recordedCall struct {
|
|
||||||
Tool string
|
|
||||||
Args map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeGiteaMCP) handler() http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Params json.RawMessage `json:"params"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
var p struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Arguments json.RawMessage `json:"arguments"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(req.Params, &p)
|
|
||||||
var args map[string]any
|
|
||||||
_ = json.Unmarshal(p.Arguments, &args)
|
|
||||||
|
|
||||||
f.mu.Lock()
|
|
||||||
f.Calls = append(f.Calls, recordedCall{Tool: p.Name, Args: args})
|
|
||||||
errResp, hasErr := f.Errors[p.Name]
|
|
||||||
var resp any
|
|
||||||
if r, ok := f.Responses[p.Name]; ok {
|
|
||||||
resp = r
|
|
||||||
} else {
|
|
||||||
resp = map[string]any{"html_url": "http://gitea.example/" + p.Name}
|
|
||||||
}
|
|
||||||
f.mu.Unlock()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if hasErr {
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req.ID,
|
|
||||||
"error": map[string]any{"code": errResp.Code, "message": errResp.Message},
|
|
||||||
})
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respText, _ := json.Marshal(resp)
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req.ID,
|
|
||||||
"result": map[string]any{
|
|
||||||
"content": []map[string]any{{"type": "text", "text": string(respText)}},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSkill(t *testing.T, f *fakeGiteaMCP) (*project.Skill, *fakeGitHub) {
|
|
||||||
t.Helper()
|
|
||||||
srv := httptest.NewServer(f.handler())
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
gh := &fakeGitHub{}
|
|
||||||
ghSrv := httptest.NewServer(gh.handler())
|
|
||||||
t.Cleanup(ghSrv.Close)
|
|
||||||
|
|
||||||
return project.New(project.Config{
|
|
||||||
Client: mustClient(t, srv.URL),
|
|
||||||
GitHub: githubclient.New("ghp_test").WithBaseURL(ghSrv.URL),
|
|
||||||
GiteaOwner: "mathias",
|
|
||||||
GitHubOwner: "mathiasb",
|
|
||||||
GitHubPAT: "ghp_test",
|
|
||||||
InfraRepo: "infra",
|
|
||||||
}), gh
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSkillNoGitHub builds a skill with the GitHub client unset — degraded
|
|
||||||
// mode where the github-repo-creation step is skipped.
|
|
||||||
func newSkillNoGitHub(t *testing.T, f *fakeGiteaMCP) *project.Skill {
|
|
||||||
t.Helper()
|
|
||||||
srv := httptest.NewServer(f.handler())
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
return project.New(project.Config{
|
|
||||||
Client: mustClient(t, srv.URL),
|
|
||||||
GiteaOwner: "mathias",
|
|
||||||
GitHubOwner: "mathiasb",
|
|
||||||
InfraRepo: "infra",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustClient builds an mcpclient against an httptest server. Uses a
|
|
||||||
// non-empty dummy token because httptest servers don't enforce bearer
|
|
||||||
// auth, but mcpclient.New now requires non-empty token (see #13).
|
|
||||||
func mustClient(t *testing.T, url string) *mcpclient.Client {
|
|
||||||
t.Helper()
|
|
||||||
c, err := mcpclient.New(url, "test-token")
|
|
||||||
require.NoError(t, err)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// happyArgs returns the minimal valid request. With the Gitea-as-true-master
|
|
||||||
// ADR shipped, this defaults to Gitea-only (mirror_to_github omitted = false).
|
|
||||||
// Tests that need the full Gitea + GitHub mirror flow use mirroredArgs().
|
|
||||||
func happyArgs() json.RawMessage {
|
|
||||||
return json.RawMessage(`{
|
|
||||||
"name":"my-experiment",
|
|
||||||
"description":"One-line desc",
|
|
||||||
"hypothesis":"We believe X produces Y",
|
|
||||||
"folder":"AGENTS",
|
|
||||||
"stack":"go-agent",
|
|
||||||
"private":true
|
|
||||||
}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mirroredArgs is happyArgs + mirror_to_github=true — the explicit opt-in
|
|
||||||
// path. Equivalent to the pre-ADR default.
|
|
||||||
func mirroredArgs() json.RawMessage {
|
|
||||||
return json.RawMessage(`{
|
|
||||||
"name":"my-experiment",
|
|
||||||
"description":"One-line desc",
|
|
||||||
"hypothesis":"We believe X produces Y",
|
|
||||||
"folder":"AGENTS",
|
|
||||||
"stack":"go-agent",
|
|
||||||
"private":true,
|
|
||||||
"mirror_to_github":true
|
|
||||||
}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCreate_HappyPath(t *testing.T) {
|
|
||||||
f := &fakeGiteaMCP{
|
|
||||||
Responses: map[string]any{
|
|
||||||
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
skill, gh := newSkill(t, f)
|
|
||||||
|
|
||||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var res map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &res))
|
|
||||||
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"])
|
|
||||||
assert.Equal(t, "https://github.com/mathiasb/my-experiment", res["github_url"])
|
|
||||||
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"])
|
|
||||||
assert.Contains(t, res["next_steps"], "cd ~/dev/AGENTS/my-experiment")
|
|
||||||
assert.Contains(t, res["next_steps"], "git remote add origin")
|
|
||||||
|
|
||||||
// All 4 gitea-mcp calls in order.
|
|
||||||
require.Len(t, f.Calls, 4)
|
|
||||||
assert.Equal(t, "create_project_from_template", f.Calls[0].Tool)
|
|
||||||
assert.Equal(t, "repo_mirror_push", f.Calls[1].Tool)
|
|
||||||
assert.Equal(t, "file_write_branch", f.Calls[2].Tool)
|
|
||||||
assert.Equal(t, "issue_create", f.Calls[3].Tool)
|
|
||||||
|
|
||||||
// GitHub repo created between create_project_from_template and mirror.
|
|
||||||
require.Len(t, gh.Calls, 1)
|
|
||||||
assert.Equal(t, "my-experiment", gh.Calls[0]["name"])
|
|
||||||
assert.Equal(t, true, gh.Calls[0]["private"])
|
|
||||||
assert.Equal(t, false, gh.Calls[0]["auto_init"])
|
|
||||||
|
|
||||||
// template selection wired from stack
|
|
||||||
assert.Equal(t, "template-go-agent", f.Calls[0].Args["template_name"])
|
|
||||||
// mirror config
|
|
||||||
assert.Equal(t, "add", f.Calls[1].Args["action"])
|
|
||||||
assert.Equal(t, "https://github.com/mathiasb/my-experiment.git", f.Calls[1].Args["remote_address"])
|
|
||||||
assert.Equal(t, "ghp_test", f.Calls[1].Args["remote_password"])
|
|
||||||
// infra commit path
|
|
||||||
assert.Equal(t, "k3s/staging/my-experiment/namespace.yaml", f.Calls[2].Args["path"])
|
|
||||||
assert.Contains(t, f.Calls[2].Args["content"], "name: staging-my-experiment")
|
|
||||||
assert.Contains(t, f.Calls[2].Args["content"], "managed-by: hyperguild")
|
|
||||||
// PAT must NOT appear in the response
|
|
||||||
assert.NotContains(t, string(out), "ghp_test")
|
|
||||||
|
|
||||||
// reached records the github step too.
|
|
||||||
reached := res["reached"].([]any)
|
|
||||||
assert.Equal(t, []any{"create_repo", "create_github_repo", "mirror", "infra_commit", "issue"}, reached)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCreate_GitHubExists_Idempotent(t *testing.T) {
|
|
||||||
f := &fakeGiteaMCP{
|
|
||||||
Responses: map[string]any{
|
|
||||||
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
skill, gh := newSkill(t, f)
|
|
||||||
gh.ReturnError = 422 // already exists
|
|
||||||
|
|
||||||
_, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
|
||||||
require.NoError(t, err, "422 already-exists should be idempotent")
|
|
||||||
require.Len(t, f.Calls, 4, "all gitea steps still run despite github 422")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCreate_GitHubFails(t *testing.T) {
|
|
||||||
f := &fakeGiteaMCP{}
|
|
||||||
skill, gh := newSkill(t, f)
|
|
||||||
gh.ReturnError = 401 // bad PAT
|
|
||||||
|
|
||||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
|
||||||
require.Error(t, err)
|
|
||||||
var res map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &res))
|
|
||||||
assert.Equal(t, "create_github_repo", res["failed_step"])
|
|
||||||
assert.Equal(t, []any{"create_repo"}, res["reached"])
|
|
||||||
require.Len(t, f.Calls, 1, "mirror + later steps must not run when github creation fails")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCreate_NoGitHubClient_DegradedMode(t *testing.T) {
|
|
||||||
f := &fakeGiteaMCP{
|
|
||||||
Responses: map[string]any{
|
|
||||||
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
skill := newSkillNoGitHub(t, f)
|
|
||||||
|
|
||||||
// Use mirroredArgs so we exercise the GitHub-mirror path. With the
|
|
||||||
// GitHub client nil, the create_github_repo step is skipped but the
|
|
||||||
// mirror step still attempts to configure the push-mirror remote
|
|
||||||
// (degraded mode preserves the prior contract for opted-in projects).
|
|
||||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
|
||||||
require.NoError(t, err)
|
|
||||||
var res map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &res))
|
|
||||||
// reached does NOT include create_github_repo when client is nil.
|
|
||||||
reached := res["reached"].([]any)
|
|
||||||
assert.Equal(t, []any{"create_repo", "mirror", "infra_commit", "issue"}, reached)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCreate_Idempotent_RepoExists(t *testing.T) {
|
|
||||||
f := &fakeGiteaMCP{
|
|
||||||
Errors: map[string]rpcErr{
|
|
||||||
"create_project_from_template": {Code: -32003, Message: "already exists"},
|
|
||||||
},
|
|
||||||
Responses: map[string]any{
|
|
||||||
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
skill, _ := newSkill(t, f)
|
|
||||||
|
|
||||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var res map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &res))
|
|
||||||
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"])
|
|
||||||
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"])
|
|
||||||
|
|
||||||
// Still ran all 4 gitea-mcp steps; idempotent flow falls through.
|
|
||||||
require.Len(t, f.Calls, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCreate_MirrorFails(t *testing.T) {
|
|
||||||
f := &fakeGiteaMCP{
|
|
||||||
Errors: map[string]rpcErr{
|
|
||||||
"repo_mirror_push": {Code: -32000, Message: "github unreachable"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
skill, _ := newSkill(t, f)
|
|
||||||
|
|
||||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), `"mirror" failed`)
|
|
||||||
|
|
||||||
var res map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &res))
|
|
||||||
assert.Equal(t, "mirror", res["failed_step"])
|
|
||||||
reached := res["reached"].([]any)
|
|
||||||
assert.Equal(t, []any{"create_repo", "create_github_repo"}, reached)
|
|
||||||
|
|
||||||
// Steps 1 (create) + 2 (mirror attempt) reached gitea; github made 1 call.
|
|
||||||
require.Len(t, f.Calls, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCreate_InfraCommitFails(t *testing.T) {
|
|
||||||
f := &fakeGiteaMCP{
|
|
||||||
Errors: map[string]rpcErr{
|
|
||||||
"file_write_branch": {Code: -32000, Message: "write rejected"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
skill, _ := newSkill(t, f)
|
|
||||||
|
|
||||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
var res map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &res))
|
|
||||||
assert.Equal(t, "infra_commit", res["failed_step"])
|
|
||||||
reached := res["reached"].([]any)
|
|
||||||
assert.Equal(t, []any{"create_repo", "create_github_repo", "mirror"}, reached)
|
|
||||||
require.Len(t, f.Calls, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCreate_ValidationErrors(t *testing.T) {
|
|
||||||
f := &fakeGiteaMCP{}
|
|
||||||
skill, _ := newSkill(t, f)
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"missing name", `{"description":"d","hypothesis":"h","stack":"go-agent"}`, "name"},
|
|
||||||
{"missing description", `{"name":"x","hypothesis":"h","stack":"go-agent"}`, "description"},
|
|
||||||
{"missing hypothesis", `{"name":"x","description":"d","stack":"go-agent"}`, "hypothesis"},
|
|
||||||
{"bad stack", `{"name":"x","description":"d","hypothesis":"h","stack":"python"}`, "stack"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
_, err := skill.Handle(context.Background(), "project_create", json.RawMessage(tc.body))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.True(t, strings.Contains(err.Error(), tc.want), "want %q in %v", tc.want, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
assert.Empty(t, f.Calls, "no upstream calls should occur on validation failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCreate_DefaultSkipsGitHubMirror(t *testing.T) {
|
|
||||||
// Default (mirror_to_github omitted) skips create_github_repo + mirror
|
|
||||||
// per the Gitea-as-true-master ADR. Gitea repo + staging namespace
|
|
||||||
// + issue still run; github_url is empty in the response.
|
|
||||||
f := &fakeGiteaMCP{
|
|
||||||
Responses: map[string]any{
|
|
||||||
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
skill, gh := newSkill(t, f)
|
|
||||||
|
|
||||||
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var res map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &res))
|
|
||||||
|
|
||||||
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"])
|
|
||||||
assert.Equal(t, "", res["github_url"], "github_url must be empty when mirror not opted in")
|
|
||||||
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"])
|
|
||||||
|
|
||||||
// 3 gitea-mcp calls: template create, staging file write, issue. NO mirror call.
|
|
||||||
require.Len(t, f.Calls, 3)
|
|
||||||
assert.Equal(t, "create_project_from_template", f.Calls[0].Tool)
|
|
||||||
assert.Equal(t, "file_write_branch", f.Calls[1].Tool)
|
|
||||||
assert.Equal(t, "issue_create", f.Calls[2].Tool)
|
|
||||||
|
|
||||||
// Zero GitHub API calls.
|
|
||||||
assert.Empty(t, gh.Calls, "no GitHub repo created when mirror_to_github is false")
|
|
||||||
|
|
||||||
// reached lists the Gitea-only path.
|
|
||||||
reached := res["reached"].([]any)
|
|
||||||
assert.Equal(t, []any{"create_repo", "infra_commit", "issue"}, reached)
|
|
||||||
|
|
||||||
// experiment-brief body reflects Gitea-only provisioning.
|
|
||||||
require.Contains(t, f.Calls[2].Args["body"], "Gitea-only")
|
|
||||||
require.NotContains(t, f.Calls[2].Args["body"], "Push-mirror configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectCreate_UnknownTool(t *testing.T) {
|
|
||||||
f := &fakeGiteaMCP{}
|
|
||||||
skill, _ := newSkill(t, f)
|
|
||||||
_, err := skill.Handle(context.Background(), "nope", happyArgs())
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// Package project implements the `project_create` MCP tool: a single-call
|
|
||||||
// pipeline that creates a Gitea repo from a template, configures push-mirror
|
|
||||||
// to GitHub, commits a staging namespace manifest to the infra repo, and
|
|
||||||
// opens an experiment-brief issue on the new repo. See hyperguild gitea
|
|
||||||
// issue #10 for the design.
|
|
||||||
package project
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/githubclient"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds the orchestration dependencies for the project skill.
|
|
||||||
type Config struct {
|
|
||||||
// Client talks to the gitea-mcp server. project_create makes
|
|
||||||
// sequential calls (create_project_from_template, repo_mirror_push,
|
|
||||||
// file_write_branch, issue_create) through this client.
|
|
||||||
Client *mcpclient.Client
|
|
||||||
|
|
||||||
// GitHub is the client used to create the empty destination repo on
|
|
||||||
// GitHub before the push-mirror is configured. Gitea's push-mirror
|
|
||||||
// cannot push to a non-existent remote, so this step is mandatory
|
|
||||||
// when GitHubPAT is set. Pass nil to skip github repo creation
|
|
||||||
// entirely (degraded mode — mirror config will land but the actual
|
|
||||||
// sync to github will fail until the repo exists).
|
|
||||||
GitHub *githubclient.Client
|
|
||||||
|
|
||||||
// GiteaOwner is the org/user that owns the new repo and the infra repo
|
|
||||||
// the namespace manifest is committed to (typically "mathias").
|
|
||||||
GiteaOwner string
|
|
||||||
|
|
||||||
// GitHubOwner is the GitHub org/user the push-mirror targets
|
|
||||||
// (typically "mathiasb").
|
|
||||||
GitHubOwner string
|
|
||||||
|
|
||||||
// GitHubPAT is the personal access token used as the push-mirror
|
|
||||||
// password and to create the destination repo on GitHub. Must have
|
|
||||||
// `repo` scope. Never logged.
|
|
||||||
GitHubPAT string
|
|
||||||
|
|
||||||
// InfraRepo is the name of the infra repo on Gitea where the
|
|
||||||
// k3s/staging/<name>/namespace.yaml manifest gets committed
|
|
||||||
// (typically "infra").
|
|
||||||
InfraRepo string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skill exposes project_create as an MCP tool.
|
|
||||||
type Skill struct{ cfg Config }
|
|
||||||
|
|
||||||
// New constructs the project Skill.
|
|
||||||
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
|
|
||||||
|
|
||||||
// Name returns the skill identifier.
|
|
||||||
func (s *Skill) Name() string { return "project" }
|
|
||||||
|
|
||||||
// Tools returns the MCP tool definitions for this skill.
|
|
||||||
func (s *Skill) Tools() []registry.ToolDef {
|
|
||||||
schema, _ := json.Marshal(map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"name": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"pattern": `^[a-z][a-z0-9-]{1,38}[a-z0-9]$`,
|
|
||||||
"description": "Lowercase repo name. 3-40 chars, must start with a letter.",
|
|
||||||
},
|
|
||||||
"description": map[string]any{"type": "string"},
|
|
||||||
"hypothesis": map[string]any{"type": "string"},
|
|
||||||
"folder": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "Informational only — appears in next_steps. Example: AGENTS, AI, QKX.",
|
|
||||||
},
|
|
||||||
"stack": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"enum": []string{"go-agent", "go-web"},
|
|
||||||
"description": "Selects template-go-agent or template-go-web.",
|
|
||||||
},
|
|
||||||
"private": map[string]any{"type": "boolean"},
|
|
||||||
"mirror_to_github": map[string]any{
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Default false. When true, also create an empty GitHub repo " +
|
|
||||||
"and configure a push-mirror from Gitea. Opt-in per the Gitea-as-true-master " +
|
|
||||||
"ADR — only set true for open-source projects (hyperguild, gitea-mcp, template-*). " +
|
|
||||||
"Never set true for client projects, business logic, or personal experiments.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"name", "description", "hypothesis", "stack"},
|
|
||||||
})
|
|
||||||
return []registry.ToolDef{
|
|
||||||
{
|
|
||||||
Name: "project_create",
|
|
||||||
Description: "Bootstrap a new project: Gitea repo from template, staging namespace manifest, " +
|
|
||||||
"experiment-brief issue. Optionally mirrors to GitHub when `mirror_to_github: true` " +
|
|
||||||
"(default false). Idempotent — re-running with an existing repo returns the existing URLs.",
|
|
||||||
InputSchema: schema,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle dispatches the tool call.
|
|
||||||
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
||||||
if tool != "project_create" {
|
|
||||||
return nil, errUnknownTool(tool)
|
|
||||||
}
|
|
||||||
return s.handleCreate(ctx, args)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
// internal/skills/retrospective/handlers.go
|
|
||||||
package retrospective
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
type retroArgs struct {
|
|
||||||
SessionID string `json:"session_id"`
|
|
||||||
Model string `json:"model,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle dispatches the retrospective tool call.
|
|
||||||
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
||||||
if tool != "retrospective" {
|
|
||||||
return nil, fmt.Errorf("unknown retrospective tool: %s", tool)
|
|
||||||
}
|
|
||||||
var a retroArgs
|
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
|
||||||
}
|
|
||||||
if a.SessionID == "" {
|
|
||||||
return nil, fmt.Errorf("session_id is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
model := a.Model
|
|
||||||
if model == "" {
|
|
||||||
model = s.cfg.DefaultModel
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := session.Read(s.cfg.SessionsDir, a.SessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read session log: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logJSON, err := json.MarshalIndent(entries, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("marshal session log: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
taskPrompt := fmt.Sprintf(
|
|
||||||
"SESSION_ID: %s\n\nSESSION_LOG:\n%s\n\nReview this session log. Identify what is novel or worth preserving as organizational knowledge. Provide structured insights.",
|
|
||||||
a.SessionID, string(logJSON),
|
|
||||||
)
|
|
||||||
|
|
||||||
if s.cfg.CompleteFunc == nil {
|
|
||||||
return nil, fmt.Errorf("no executor configured")
|
|
||||||
}
|
|
||||||
t0 := time.Now()
|
|
||||||
text, dur, err := s.cfg.CompleteFunc(ctx, model, s.cfg.SkillPrompt, taskPrompt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("retrospective model: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := text
|
|
||||||
if len(msg) > 200 {
|
|
||||||
msg = msg[:200]
|
|
||||||
}
|
|
||||||
_ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{
|
|
||||||
SessionID: a.SessionID,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Skill: "retrospective",
|
|
||||||
Phase: "retrospective",
|
|
||||||
FinalStatus: "ok",
|
|
||||||
ModelUsed: model,
|
|
||||||
DurationMs: time.Since(t0).Milliseconds(),
|
|
||||||
Message: msg,
|
|
||||||
})
|
|
||||||
|
|
||||||
return json.Marshal(map[string]any{"text": text, "model": model, "duration_ms": dur})
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// internal/skills/retrospective/handlers_test.go
|
|
||||||
package retrospective_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHandle_Retrospective_RequiresSessionID(t *testing.T) {
|
|
||||||
s := retrospective.New(retrospective.Config{})
|
|
||||||
_, err := s.Handle(context.Background(), "retrospective", json.RawMessage(`{}`))
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "session_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandle_Retrospective_BuildsPromptWithSessionLog(t *testing.T) {
|
|
||||||
var capturedTask string
|
|
||||||
s := retrospective.New(retrospective.Config{
|
|
||||||
SkillPrompt: "retrospective discipline",
|
|
||||||
DefaultModel: "ollama/test",
|
|
||||||
SessionsDir: t.TempDir(),
|
|
||||||
CompleteFunc: func(_ context.Context, _, _, user string) (string, int64, error) {
|
|
||||||
capturedTask = user
|
|
||||||
return "Key insight: the team resolved a tricky nil pointer issue via careful logging.", 75, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
args, _ := json.Marshal(map[string]string{"session_id": "empty-session"})
|
|
||||||
out, err := s.Handle(context.Background(), "retrospective", args)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var result map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &result))
|
|
||||||
assert.Contains(t, result["text"], "nil pointer")
|
|
||||||
assert.Contains(t, capturedTask, "empty-session")
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// internal/skills/retrospective/skill.go
|
|
||||||
package retrospective
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CompleteFunc is the function used to call a local model.
|
|
||||||
type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error)
|
|
||||||
|
|
||||||
// Config holds retrospective skill configuration.
|
|
||||||
type Config struct {
|
|
||||||
SkillPrompt string
|
|
||||||
DefaultModel string
|
|
||||||
SessionsDir string
|
|
||||||
CompleteFunc CompleteFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skill implements registry.Skill for the retrospective tool.
|
|
||||||
type Skill struct {
|
|
||||||
cfg Config
|
|
||||||
}
|
|
||||||
|
|
||||||
// New constructs a retrospective Skill.
|
|
||||||
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
|
|
||||||
|
|
||||||
// Name returns the skill name.
|
|
||||||
func (s *Skill) Name() string { return "retrospective" }
|
|
||||||
|
|
||||||
// Tools returns the MCP tool definitions.
|
|
||||||
func (s *Skill) Tools() []registry.ToolDef {
|
|
||||||
return []registry.ToolDef{
|
|
||||||
{
|
|
||||||
Name: "retrospective",
|
|
||||||
Description: "Consult a local model to analyse a completed session and identify what is novel or worth preserving as organizational knowledge.",
|
|
||||||
InputSchema: json.RawMessage(`{
|
|
||||||
"type": "object",
|
|
||||||
"required": ["session_id"],
|
|
||||||
"properties": {
|
|
||||||
"session_id": {"type": "string"},
|
|
||||||
"model": {"type": "string"}
|
|
||||||
}
|
|
||||||
}`),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// internal/skills/review/handlers.go
|
|
||||||
package review
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/brain"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
type reviewArgs struct {
|
|
||||||
ProjectRoot string `json:"project_root"`
|
|
||||||
Files []string `json:"files"`
|
|
||||||
Context string `json:"context"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
SessionID string `json:"session_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle dispatches the MCP tool call to the appropriate handler.
|
|
||||||
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
||||||
if tool != "review" {
|
|
||||||
return nil, fmt.Errorf("unknown tool: %s", tool)
|
|
||||||
}
|
|
||||||
var a reviewArgs
|
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
|
||||||
}
|
|
||||||
if a.ProjectRoot == "" {
|
|
||||||
return nil, fmt.Errorf("project_root is required")
|
|
||||||
}
|
|
||||||
if len(a.Files) == 0 {
|
|
||||||
return nil, fmt.Errorf("files is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
model := a.Model
|
|
||||||
if model == "" {
|
|
||||||
model = s.cfg.DefaultModel
|
|
||||||
}
|
|
||||||
|
|
||||||
brainCtx, _ := brain.Query(ctx, s.cfg.IngestBaseURL, strings.Join(a.Files, " ")+" "+a.Context, 3)
|
|
||||||
|
|
||||||
task := fmt.Sprintf(
|
|
||||||
"phase: review\nproject_root: %s\nfiles: %s\ncontext: %s\nmodel: %s",
|
|
||||||
a.ProjectRoot, strings.Join(a.Files, ", "), a.Context, model,
|
|
||||||
)
|
|
||||||
task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "review", task)
|
|
||||||
if brainCtx != "" {
|
|
||||||
task = brainCtx + "\n---\n\n" + task
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.cfg.CompleteFunc == nil {
|
|
||||||
return nil, fmt.Errorf("no executor configured")
|
|
||||||
}
|
|
||||||
t0 := time.Now()
|
|
||||||
text, dur, err := s.cfg.CompleteFunc(ctx, model, s.cfg.SkillPrompt, task)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.SessionID != "" && s.cfg.SessionsDir != "" {
|
|
||||||
msg := text
|
|
||||||
if len(msg) > 200 {
|
|
||||||
msg = msg[:200]
|
|
||||||
}
|
|
||||||
_ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{
|
|
||||||
SessionID: a.SessionID,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Skill: "review",
|
|
||||||
Phase: "review",
|
|
||||||
ProjectRoot: a.ProjectRoot,
|
|
||||||
FinalStatus: "ok",
|
|
||||||
ModelUsed: model,
|
|
||||||
DurationMs: time.Since(t0).Milliseconds(),
|
|
||||||
Message: msg,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(map[string]any{"text": text, "model": model, "duration_ms": dur})
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// internal/skills/review/handlers_test.go
|
|
||||||
package review_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/review"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestReviewToolRegistered(t *testing.T) {
|
|
||||||
sk := review.New(review.Config{SkillPrompt: "review rules"})
|
|
||||||
names := make([]string, 0)
|
|
||||||
for _, tool := range sk.Tools() {
|
|
||||||
names = append(names, tool.Name)
|
|
||||||
}
|
|
||||||
assert.Contains(t, names, "review")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewRequiresProjectRoot(t *testing.T) {
|
|
||||||
sk := review.New(review.Config{SkillPrompt: "r"})
|
|
||||||
_, err := sk.Handle(context.Background(), "review", json.RawMessage(`{"files":["main.go"]}`))
|
|
||||||
assert.ErrorContains(t, err, "project_root")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewRequiresFiles(t *testing.T) {
|
|
||||||
sk := review.New(review.Config{SkillPrompt: "r"})
|
|
||||||
_, err := sk.Handle(context.Background(), "review", json.RawMessage(`{"project_root":"/tmp"}`))
|
|
||||||
assert.ErrorContains(t, err, "files")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewCallsCompleteFunc(t *testing.T) {
|
|
||||||
var capturedTask string
|
|
||||||
fakeFn := func(_ context.Context, _, _, user string) (string, int64, error) {
|
|
||||||
capturedTask = user
|
|
||||||
return "2 warnings found: missing error handling at line 42", 80, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sk := review.New(review.Config{SkillPrompt: "review rules", CompleteFunc: fakeFn, SessionsDir: t.TempDir()})
|
|
||||||
out, err := sk.Handle(context.Background(), "review", json.RawMessage(
|
|
||||||
`{"project_root":"/tmp/proj","files":["internal/foo/foo.go"],"context":"PR: add Foo helper"}`,
|
|
||||||
))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, capturedTask, "internal/foo/foo.go")
|
|
||||||
assert.Contains(t, capturedTask, "PR: add Foo helper")
|
|
||||||
|
|
||||||
var result map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &result))
|
|
||||||
assert.Contains(t, result["text"], "2 warnings found")
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
// internal/skills/review/skill.go
|
|
||||||
package review
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CompleteFunc is the function used to call a local model.
|
|
||||||
type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error)
|
|
||||||
|
|
||||||
// Config holds dependencies for the review skill.
|
|
||||||
type Config struct {
|
|
||||||
SkillPrompt string
|
|
||||||
DefaultModel string
|
|
||||||
CompleteFunc CompleteFunc
|
|
||||||
SessionsDir string
|
|
||||||
IngestBaseURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skill implements the review MCP tool.
|
|
||||||
type Skill struct{ cfg Config }
|
|
||||||
|
|
||||||
// New creates a new review Skill.
|
|
||||||
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
|
|
||||||
|
|
||||||
// Name returns the skill identifier.
|
|
||||||
func (s *Skill) Name() string { return "review" }
|
|
||||||
|
|
||||||
// Tools returns the MCP tool definitions for this skill.
|
|
||||||
func (s *Skill) Tools() []registry.ToolDef {
|
|
||||||
schema := func(required []string, props map[string]any) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
str := map[string]any{"type": "string"}
|
|
||||||
return []registry.ToolDef{
|
|
||||||
{
|
|
||||||
Name: "review",
|
|
||||||
Description: "Consult a local model for a structured code review of the specified files. Returns findings with severity levels.",
|
|
||||||
InputSchema: schema(
|
|
||||||
[]string{"project_root", "files"},
|
|
||||||
map[string]any{
|
|
||||||
"project_root": str,
|
|
||||||
"files": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
||||||
"context": str,
|
|
||||||
"model": str,
|
|
||||||
"session_id": str,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
// internal/skills/trainer/handlers.go
|
|
||||||
package trainer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
type trainArgs struct {
|
|
||||||
SessionID string `json:"session_id"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle dispatches the MCP tool call to the trainer handler.
|
|
||||||
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
||||||
if tool != "trainer" {
|
|
||||||
return nil, fmt.Errorf("unknown tool: %s", tool)
|
|
||||||
}
|
|
||||||
var a trainArgs
|
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
|
||||||
}
|
|
||||||
if a.SessionID == "" {
|
|
||||||
return nil, fmt.Errorf("session_id is required")
|
|
||||||
}
|
|
||||||
if s.cfg.CompleteFunc == nil {
|
|
||||||
return nil, fmt.Errorf("no executor configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
model := a.Model
|
|
||||||
if model == "" {
|
|
||||||
model = s.cfg.DefaultModel
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := session.Read(s.cfg.SessionsDir, a.SessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read session log: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 1: Reader ────────────────────────────────────────────────────────
|
|
||||||
history := session.FormatHistory(entries, "")
|
|
||||||
readerTask := fmt.Sprintf(
|
|
||||||
"role: reader\nsession_id: %s\nbrain_dir: %s\n\n%s",
|
|
||||||
a.SessionID, s.cfg.BrainDir, history,
|
|
||||||
)
|
|
||||||
readerText, _, err := s.cfg.CompleteFunc(ctx, model, s.cfg.ReaderPrompt, readerTask)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("reader: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 2: Writer (receives reader output) ───────────────────────────────
|
|
||||||
t0 := time.Now()
|
|
||||||
writerTask := fmt.Sprintf(
|
|
||||||
"role: writer\nsession_id: %s\nbrain_dir: %s\n\nreader_analysis:\n%s",
|
|
||||||
a.SessionID, s.cfg.BrainDir, readerText,
|
|
||||||
)
|
|
||||||
writerText, dur, err := s.cfg.CompleteFunc(ctx, model, s.cfg.WriterPrompt, writerTask)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("writer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := writerText
|
|
||||||
if len(msg) > 200 {
|
|
||||||
msg = msg[:200]
|
|
||||||
}
|
|
||||||
_ = session.Append(s.cfg.SessionsDir, a.SessionID, session.Entry{
|
|
||||||
SessionID: a.SessionID,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Skill: "trainer",
|
|
||||||
Phase: "trainer",
|
|
||||||
FinalStatus: "ok",
|
|
||||||
ModelUsed: model,
|
|
||||||
DurationMs: time.Since(t0).Milliseconds(),
|
|
||||||
Message: msg,
|
|
||||||
})
|
|
||||||
|
|
||||||
return json.Marshal(map[string]any{
|
|
||||||
"reader_analysis": readerText,
|
|
||||||
"writer_output": writerText,
|
|
||||||
"model": model,
|
|
||||||
"duration_ms": dur,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// internal/skills/trainer/handlers_test.go
|
|
||||||
package trainer_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/trainer"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTrainerToolRegistered(t *testing.T) {
|
|
||||||
sk := trainer.New(trainer.Config{ReaderPrompt: "r", WriterPrompt: "w"})
|
|
||||||
names := make([]string, 0)
|
|
||||||
for _, tool := range sk.Tools() {
|
|
||||||
names = append(names, tool.Name)
|
|
||||||
}
|
|
||||||
assert.Contains(t, names, "trainer")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTrainerRequiresSessionID(t *testing.T) {
|
|
||||||
sk := trainer.New(trainer.Config{ReaderPrompt: "r", WriterPrompt: "w"})
|
|
||||||
_, err := sk.Handle(context.Background(), "trainer", json.RawMessage(`{}`))
|
|
||||||
assert.ErrorContains(t, err, "session_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTrainerCallsReaderThenWriter(t *testing.T) {
|
|
||||||
sessDir := t.TempDir()
|
|
||||||
require.NoError(t, session.Append(sessDir, "sess-1", session.Entry{
|
|
||||||
SessionID: "sess-1", Skill: "tdd", Phase: "red", FinalStatus: "ok",
|
|
||||||
Message: "wrote failing test", FilePath: "internal/foo/foo_test.go",
|
|
||||||
}))
|
|
||||||
|
|
||||||
callCount := 0
|
|
||||||
var readerTask, writerTask string
|
|
||||||
|
|
||||||
fakeFn := func(_ context.Context, _, sys, user string) (string, int64, error) {
|
|
||||||
callCount++
|
|
||||||
if callCount == 1 {
|
|
||||||
// reader call
|
|
||||||
readerTask = user
|
|
||||||
return "1 sft candidate found: first-pass clean TDD", 60, nil
|
|
||||||
}
|
|
||||||
// writer call
|
|
||||||
writerTask = user
|
|
||||||
return "written 1 knowledge entry to brain/knowledge/tdd-patterns.md", 70, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sk := trainer.New(trainer.Config{
|
|
||||||
ReaderPrompt: "reader rules",
|
|
||||||
WriterPrompt: "writer rules",
|
|
||||||
CompleteFunc: fakeFn,
|
|
||||||
SessionsDir: sessDir,
|
|
||||||
BrainDir: t.TempDir(),
|
|
||||||
})
|
|
||||||
out, err := sk.Handle(context.Background(), "trainer", json.RawMessage(`{"session_id":"sess-1"}`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, 2, callCount, "complete must be called exactly twice: reader then writer")
|
|
||||||
assert.Contains(t, readerTask, "role: reader")
|
|
||||||
assert.Contains(t, readerTask, "sess-1")
|
|
||||||
assert.Contains(t, readerTask, "wrote failing test")
|
|
||||||
assert.Contains(t, writerTask, "role: writer")
|
|
||||||
assert.Contains(t, writerTask, "sft candidate")
|
|
||||||
|
|
||||||
var result map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(out, &result))
|
|
||||||
assert.Contains(t, result["reader_analysis"], "sft candidate")
|
|
||||||
assert.Contains(t, result["writer_output"], "knowledge entry")
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
// internal/skills/trainer/skill.go
|
|
||||||
package trainer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CompleteFunc is the function used to call a local model.
|
|
||||||
type CompleteFunc func(ctx context.Context, model, system, user string) (string, int64, error)
|
|
||||||
|
|
||||||
// Config holds dependencies for the trainer skill.
|
|
||||||
type Config struct {
|
|
||||||
ReaderPrompt string
|
|
||||||
WriterPrompt string
|
|
||||||
DefaultModel string
|
|
||||||
CompleteFunc CompleteFunc
|
|
||||||
SessionsDir string
|
|
||||||
BrainDir string // root of brain/ directory
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skill implements the trainer MCP tool.
|
|
||||||
type Skill struct{ cfg Config }
|
|
||||||
|
|
||||||
// New creates a new trainer Skill.
|
|
||||||
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
|
|
||||||
|
|
||||||
// Name returns the skill identifier.
|
|
||||||
func (s *Skill) Name() string { return "trainer" }
|
|
||||||
|
|
||||||
// Tools returns the MCP tool definitions for this skill.
|
|
||||||
func (s *Skill) Tools() []registry.ToolDef {
|
|
||||||
schema := func(required []string, props map[string]any) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
return []registry.ToolDef{
|
|
||||||
{
|
|
||||||
Name: "trainer",
|
|
||||||
Description: "Consult a local model to identify learning moments from a session log and suggest knowledge to preserve in the brain.",
|
|
||||||
InputSchema: schema(
|
|
||||||
[]string{"session_id"},
|
|
||||||
map[string]any{
|
|
||||||
"session_id": map[string]any{"type": "string"},
|
|
||||||
"model": map[string]any{"type": "string"},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user