Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dcd60931a | ||
|
|
1fac90ed2a | ||
|
|
cb9c2513a4 | ||
|
|
3617a6c386 | ||
|
|
1938170131 | ||
|
|
b34717e6b8 | ||
|
|
d8d7e9a307 | ||
|
|
f0055483a3 | ||
|
|
6d014c1d0f | ||
|
|
1001acfb44 | ||
|
|
6ad275b505 | ||
|
|
b600cc986c | ||
|
|
9bdab1c48c | ||
|
|
6520c2fc4b | ||
|
|
fcbd1072b6 | ||
|
|
3b7706b358 | ||
|
|
394a227877 | ||
|
|
0f84ab5eda | ||
|
|
5b57843346 | ||
|
|
ee1204d76b | ||
|
|
6d58336ce2 | ||
|
|
0785f14220 | ||
|
|
a7db0dd00d | ||
|
|
fb59c390e2 | ||
|
|
00e5f62c8e | ||
|
|
b9d03316fd | ||
|
|
da9bdc4cbb | ||
|
|
dcb9ff4a56 | ||
|
|
0454527b83 | ||
|
|
ef11864121 | ||
|
|
14b04a25cb | ||
|
|
66a9b8e725 | ||
|
|
9f8fb9c138 | ||
|
|
d39a18dd69 | ||
|
|
5288554338 | ||
|
|
2368564523 | ||
|
|
0e28b2125b | ||
|
|
723dab51ae | ||
|
|
06e21c019e | ||
|
|
76514215f4 | ||
|
|
7cf5bc221d | ||
|
|
f78a5474a5 | ||
|
|
c307b72bd5 | ||
|
|
38a2e91002 | ||
|
|
77f5e06d6b | ||
|
|
202212e8d5 | ||
|
|
b7938d4636 | ||
|
|
a1997838b0 | ||
|
|
77680c7445 | ||
|
|
d7a842f356 | ||
|
|
aad90f2dfe | ||
|
|
07fca9ee73 | ||
|
|
f6bf9b5f57 | ||
|
|
6606b38a76 | ||
|
|
4cfc98de56 | ||
|
|
0ac165cca3 | ||
|
|
43f92e3102 | ||
|
|
98cfae595c | ||
|
|
2a595b5a92 | ||
|
|
b7a2cc5fdf | ||
|
|
db638cca11 | ||
|
|
38579598e0 | ||
|
|
d6fa92b176 |
@@ -88,7 +88,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
@@ -97,7 +97,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
|
||||||
|
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
|
||||||
|
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
|
||||||
|
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
|
||||||
|
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
@@ -268,7 +273,7 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
**Skill trigger table — load before starting, not after getting stuck:**
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
|
|||||||
+20
-65
@@ -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
|
||||||
@@ -71,9 +48,23 @@ jobs:
|
|||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||||
chmod 600 ~/.ssh/infra_deploy_key
|
chmod 600 ~/.ssh/infra_deploy_key
|
||||||
printf 'Host git.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
# In-cluster DNS to gitea's SSH NodePort service, not 127.0.0.1:30022
|
||||||
|
# (that only worked when act_runner ran on koala's bare host network;
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
# from inside the containerized runner's own pod netns, loopback
|
||||||
|
# never reaches the host — "Connection refused", found 2026-07-27).
|
||||||
|
#
|
||||||
|
# Pass as -o overrides on the ssh invocation itself, NOT appended to
|
||||||
|
# ~/.ssh/config: $HOME (/data) is a PVC that persists across job
|
||||||
|
# runs on this runner (same "workspace not ephemeral" class as
|
||||||
|
# brain: act-runner-host-executor-tmp-persists), so an appended
|
||||||
|
# line here would pile up duplicate `Host git.d-ma.be` blocks
|
||||||
|
# across every run — ssh_config is first-match-wins, so a stale
|
||||||
|
# entry from an earlier failed run would silently shadow this
|
||||||
|
# fix forever (exactly what happened once already: this fix's
|
||||||
|
# own first attempt got appended AFTER an already-stale entry
|
||||||
|
# and lost). CLI -o options always win regardless of file state,
|
||||||
|
# so this step is safe to re-run any number of times.
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes -o HostName=gitea-ssh-nodeport.gitea.svc.cluster.local -o Port=22 -o StrictHostKeyChecking=no" \
|
||||||
git clone "${INFRA_REPO}" /tmp/infra-update
|
git clone "${INFRA_REPO}" /tmp/infra-update
|
||||||
|
|
||||||
cd /tmp/infra-update
|
cd /tmp/infra-update
|
||||||
@@ -81,18 +72,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 +119,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}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
@@ -92,7 +92,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
|
||||||
|
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
|
||||||
|
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
|
||||||
|
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
|
||||||
|
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
@@ -263,7 +268,7 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
**Skill trigger table — load before starting, not after getting stuck:**
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/tier"
|
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultAnthropicProbe = "https://api.anthropic.com"
|
const defaultAnthropicProbe = "https://api.anthropic.com"
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/mathiasbq/supervisor
|
module git.d-ma.be/mathias/hyperguild
|
||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
|
|||||||
+152
-14
@@ -8,15 +8,21 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"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/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
||||||
@@ -28,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
|
||||||
@@ -61,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +143,47 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
|
||||||
|
// set it builds the classification-aware DegradingSink (loki central +
|
||||||
|
// durable file buffer + optional ntfy) and starts the reconcile loop;
|
||||||
|
// otherwise it falls back to a plain slog sink. The buffer lives under the
|
||||||
|
// brain dir so it survives process restarts.
|
||||||
|
func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) capture.AuditSink {
|
||||||
|
lokiURL := os.Getenv("BRAIN_LOKI_URL")
|
||||||
|
central := audit.NewLokiCentral(lokiURL)
|
||||||
|
if central == nil {
|
||||||
|
logger.Info("capture audit: slog sink (BRAIN_LOKI_URL unset)")
|
||||||
|
return audit.NewSlogSink(logger)
|
||||||
|
}
|
||||||
|
buffer, err := audit.NewFileBuffer(filepath.Join(brainDir, ".audit-buffer", "capture.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("capture audit buffer init", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Keep notifier as a nil interface (not a typed-nil) when unconfigured
|
||||||
|
// so DegradingSink/Reconcile skip it cleanly.
|
||||||
|
var notifier audit.Notifier
|
||||||
|
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||||
|
notifier = n
|
||||||
|
}
|
||||||
|
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
||||||
|
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
||||||
|
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
||||||
|
return audit.NewDegradingSink(central, buffer, notifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitList parses a comma-separated env value into a trimmed,
|
||||||
|
// empty-free slice. Used for the capture sovereign-principal allowlist.
|
||||||
|
func splitList(v string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range strings.Split(v, ",") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// systemHostname returns os.Hostname() with a "unknown" fallback so the
|
// systemHostname returns os.Hostname() with a "unknown" fallback so the
|
||||||
// caller never has to handle the rare error path.
|
// caller never has to handle the rare error path.
|
||||||
func systemHostname() string {
|
func systemHostname() string {
|
||||||
@@ -175,6 +241,15 @@ func main() {
|
|||||||
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gitea ticket tracker for the capture capability (#52). Token via env
|
||||||
|
// only — never logged or in argv. Both vars must be set to enable it;
|
||||||
|
// gitea.New returns nil otherwise, leaving ticket integration off.
|
||||||
|
giteaURL := envOr("BRAIN_GITEA_URL", "https://git.d-ma.be")
|
||||||
|
if tracker := gitea.New(giteaURL, os.Getenv("BRAIN_GITEA_TOKEN")); tracker != nil {
|
||||||
|
mcpSrv = mcpSrv.WithIssueTracker(tracker)
|
||||||
|
logger.Info("brain gitea tracker configured", "url", giteaURL)
|
||||||
|
}
|
||||||
|
|
||||||
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
||||||
// be set together for the path to wire on; otherwise BM25-only.
|
// be set together for the path to wire on; otherwise BM25-only.
|
||||||
var vectorStore *vectorstore.PGStore
|
var vectorStore *vectorstore.PGStore
|
||||||
@@ -296,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,
|
||||||
@@ -313,8 +411,13 @@ func main() {
|
|||||||
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||||
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||||
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||||
|
mux.HandleFunc("GET /pending", h.Pending)
|
||||||
|
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)
|
||||||
@@ -339,6 +442,41 @@ func main() {
|
|||||||
|
|
||||||
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
||||||
|
|
||||||
|
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
||||||
|
// tracker to file action items, so it only mounts when Gitea is
|
||||||
|
// configured. It reuses the MCP server's graph-wired brain store (one
|
||||||
|
// implementation), the classification tags for the I1 gate, and a
|
||||||
|
// classification-aware audit sink (loki + durable buffer + ntfy when
|
||||||
|
// BRAIN_LOKI_URL is set, else a plain slog sink). The handler does its
|
||||||
|
// own auth (static + JWT) because it needs the principal to derive the
|
||||||
|
// trust-zone origin — the chassis middleware hides it.
|
||||||
|
if tracker := mcpSrv.IssueTracker(); tracker != nil {
|
||||||
|
classCfg, cerr := classification.Load(brainDir)
|
||||||
|
if cerr != nil {
|
||||||
|
logger.Error("load classification config", "err", cerr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
auditSink := buildAuditSink(ctx, brainDir, logger)
|
||||||
|
// The Gitea client also satisfies SummaryWriter (#66): session
|
||||||
|
// summaries are written to mathias/ai-sessions over the same API
|
||||||
|
// token. nil only if a future tracker impl lacks file writes.
|
||||||
|
summaryWriter, _ := tracker.(capture.SummaryWriter)
|
||||||
|
captureSvc := capture.NewService(
|
||||||
|
mcpSrv.BrainStore(), tracker, summaryWriter, classCfg, auditSink)
|
||||||
|
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||||
|
resolver := capturehttp.NewOriginResolver(sovereign)
|
||||||
|
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
|
mux.Handle("POST /capture", captureH)
|
||||||
|
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
|
||||||
|
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
|
||||||
|
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
||||||
|
// WithCapture mutates the same instance, so the tool appears live.
|
||||||
|
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
|
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign))
|
||||||
|
} else {
|
||||||
|
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||||
|
}
|
||||||
|
|
||||||
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
|
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
|
||||||
// integration UI, which has no static-Bearer field. Setting both
|
// integration UI, which has no static-Bearer field. Setting both
|
||||||
// OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET enables the token exchange;
|
// OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET enables the token exchange;
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type writeRequest struct {
|
|||||||
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 {
|
||||||
@@ -121,6 +122,7 @@ type WriteNoteOptions struct {
|
|||||||
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
|
||||||
@@ -154,26 +156,111 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("create hall dir: %w", err)
|
return "", fmt.Errorf("create hall dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existingFields, body := splitFrontmatter(opts.Content)
|
||||||
|
existingByKey := make(map[string]frontmatterField, len(existingFields))
|
||||||
|
for _, f := range existingFields {
|
||||||
|
existingByKey[f.key] = f
|
||||||
|
}
|
||||||
|
emitted := make(map[string]bool, 6)
|
||||||
|
|
||||||
var fm strings.Builder
|
var fm strings.Builder
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
|
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
|
||||||
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
||||||
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
||||||
if opts.Type != "" {
|
emitted["wing"], emitted["hall"], emitted["created_at"] = true, true, true
|
||||||
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
|
||||||
|
// writeField merges one key: opts.Content's own value (if the note already
|
||||||
|
// carries this field in its own frontmatter) always wins over the fallback,
|
||||||
|
// so promotion/extraction-step metadata survives verbatim instead of being
|
||||||
|
// shadowed by a second, stacked frontmatter block (#86).
|
||||||
|
writeField := func(key, fallback string) {
|
||||||
|
emitted[key] = true
|
||||||
|
if f, ok := existingByKey[key]; ok {
|
||||||
|
for _, line := range f.lines {
|
||||||
|
fm.WriteString(line)
|
||||||
|
fm.WriteString("\n")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fallback != "" {
|
||||||
|
fmt.Fprintf(&fm, "%s: %s\n", key, fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeField("type", opts.Type)
|
||||||
|
writeField("domain", 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"
|
||||||
|
}
|
||||||
|
writeField("source_type", sourceType)
|
||||||
|
|
||||||
|
for _, f := range existingFields {
|
||||||
|
if emitted[f.key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range f.lines {
|
||||||
|
fm.WriteString(line)
|
||||||
|
fm.WriteString("\n")
|
||||||
}
|
}
|
||||||
if opts.Domain != "" {
|
|
||||||
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
|
||||||
}
|
}
|
||||||
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()+body), 0o644); err != nil {
|
||||||
return "", fmt.Errorf("write: %w", err)
|
return "", fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
rel, _ := filepath.Rel(brainDir, dest)
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
return filepath.ToSlash(rel), nil
|
return filepath.ToSlash(rel), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// frontmatterField is one top-level YAML key from a frontmatter block,
|
||||||
|
// along with its raw line and any indented continuation lines (e.g. a
|
||||||
|
// bulleted list value spanning multiple lines).
|
||||||
|
type frontmatterField struct {
|
||||||
|
key string
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitFrontmatter splits a leading "---\n...\n---\n" YAML block out of
|
||||||
|
// content, returning its top-level fields in original order and the
|
||||||
|
// remaining body. If content has no leading frontmatter block, fields is
|
||||||
|
// nil and body is content unchanged.
|
||||||
|
func splitFrontmatter(content string) (fields []frontmatterField, body string) {
|
||||||
|
if !strings.HasPrefix(content, "---\n") {
|
||||||
|
return nil, content
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
i := 1
|
||||||
|
var cur *frontmatterField
|
||||||
|
for ; i < len(lines); i++ {
|
||||||
|
line := lines[i]
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||||
|
if cur != nil {
|
||||||
|
fields = append(fields, *cur)
|
||||||
|
}
|
||||||
|
key, _, _ := strings.Cut(line, ":")
|
||||||
|
cur = &frontmatterField{key: strings.TrimSpace(key), lines: []string{line}}
|
||||||
|
} else if cur != nil {
|
||||||
|
cur.lines = append(cur.lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur != nil {
|
||||||
|
fields = append(fields, *cur)
|
||||||
|
}
|
||||||
|
body = strings.Join(lines[i:], "\n")
|
||||||
|
return fields, body
|
||||||
|
}
|
||||||
|
|
||||||
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
|
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
|
||||||
// callers that have not adopted the wing/hall taxonomy.
|
// callers that have not adopted the wing/hall taxonomy.
|
||||||
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||||
@@ -332,11 +419,19 @@ func (h *Handler) Ingest(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
||||||
}
|
}
|
||||||
|
|
||||||
// supportedExtensions lists file extensions that IngestPath will process.
|
// isSupportedExtension reports whether IngestPath will process ext.
|
||||||
var supportedExtensions = map[string]bool{
|
// .docx/.xlsx/.pptx/.png/.jpg/.jpeg require docmark (ADR-0013) and are only
|
||||||
".md": true,
|
// supported when DOCMARK_URL is configured — checked per-call (not cached at
|
||||||
".txt": true,
|
// package init) so it reflects the environment at request time.
|
||||||
".pdf": true,
|
func isSupportedExtension(ext string) bool {
|
||||||
|
switch ext {
|
||||||
|
case ".md", ".txt", ".pdf":
|
||||||
|
return true
|
||||||
|
case ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||||
|
return os.Getenv("DOCMARK_URL") != ""
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestPath handles POST /ingest-path — ingest a file or directory.
|
// IngestPath handles POST /ingest-path — ingest a file or directory.
|
||||||
@@ -369,7 +464,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
if !supportedExtensions[ext] {
|
if !isSupportedExtension(ext) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content, readErr := extract.Text(path)
|
content, readErr := extract.Text(path)
|
||||||
@@ -397,7 +492,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ext := strings.ToLower(filepath.Ext(req.Path))
|
ext := strings.ToLower(filepath.Ext(req.Path))
|
||||||
if !supportedExtensions[ext] {
|
if !isSupportedExtension(ext) {
|
||||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -483,6 +578,40 @@ func (h *Handler) BackfillRefs(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]int{"updated": n})
|
writeJSON(w, map[string]int{"updated": n})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pending handles GET /pending — list raw/ notes awaiting promotion.
|
||||||
|
func (h *Handler) Pending(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
pending, err := ListPending(h.brainDir)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("pending failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "pending error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"pending": pending})
|
||||||
|
}
|
||||||
|
|
||||||
|
type promoteRequest struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Wing string `json:"wing"`
|
||||||
|
Hall string `json:"hall"`
|
||||||
|
Slug string `json:"slug,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
||||||
|
// hall / collision / missing source is a 400 (caller error), not a 500.
|
||||||
|
func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req promoteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rel, err := PromoteNote(h.brainDir, PromoteOptions(req))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"path": rel})
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||||
|
|||||||
@@ -118,6 +118,116 @@ 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 TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\ntitle: act_runner host-executor\ntags: [gitea-actions, act_runner]\n---\n\n# Body\n\nSome content.\n",
|
||||||
|
Filename: "act-runner-host-executor",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "failures",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := string(got)
|
||||||
|
|
||||||
|
// exactly one frontmatter block: only two "---" delimiter lines total
|
||||||
|
assert.Equal(t, 2, strings.Count(body, "---\n"), "expected a single merged frontmatter block, not stacked blocks")
|
||||||
|
assert.Contains(t, body, "wing: homelab")
|
||||||
|
assert.Contains(t, body, "hall: failures")
|
||||||
|
assert.Contains(t, body, "title: act_runner host-executor")
|
||||||
|
assert.Contains(t, body, "tags: [gitea-actions, act_runner]")
|
||||||
|
assert.Contains(t, body, "# Body")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallRouteExistingTypeWinsOverOptsType(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\ntype: hypothesis\n---\n\nBody.\n",
|
||||||
|
Filename: "note",
|
||||||
|
Wing: "agentsquad",
|
||||||
|
Hall: "decisions",
|
||||||
|
Type: "decision", // should lose to content's own "type: hypothesis"
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "type: hypothesis")
|
||||||
|
assert.NotContains(t, string(got), "type: decision")
|
||||||
|
}
|
||||||
|
|
||||||
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,61 @@
|
|||||||
|
// ingestion/internal/api/ingestpath_docmark_test.go
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIngestPath_DocxUnsupportedWhenDocmarkNotConfigured(t *testing.T) {
|
||||||
|
t.Setenv("DOCMARK_URL", "")
|
||||||
|
_, h := setup(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"path": f, "source": "test-doc", "dry_run": true})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestPath(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestPath_DocxSupportedWhenDocmarkConfigured(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"# Converted Doc"}]}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
|
_, h := setup(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"path": f, "source": "test-doc", "dry_run": true})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestPath(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
pages, ok := resp["pages"].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.NotEmpty(t, pages)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PendingNote describes a raw/ note awaiting human promotion to the wiki.
|
||||||
|
type PendingNote struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when
|
||||||
|
// deriving the default promoted slug.
|
||||||
|
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`)
|
||||||
|
|
||||||
|
// ListPending returns the notes in brain/raw/ awaiting review, oldest-first
|
||||||
|
// (natural review order). An absent raw/ dir yields an empty slice, not an
|
||||||
|
// error. Only .md files are listed; tunnel-candidate files are skipped.
|
||||||
|
func ListPending(brainDir string) ([]PendingNote, error) {
|
||||||
|
dir := filepath.Join(brainDir, "raw")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []PendingNote{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read raw dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]PendingNote, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, statErr := e.Info()
|
||||||
|
if statErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if readErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fm, body := parseFrontmatter(string(raw))
|
||||||
|
created := fm.get("created_at")
|
||||||
|
if created == "" {
|
||||||
|
created = info.ModTime().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
out = append(out, PendingNote{
|
||||||
|
Filename: e.Name(),
|
||||||
|
CreatedAt: created,
|
||||||
|
SizeBytes: info.Size(),
|
||||||
|
Excerpt: excerpt(body, 200),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromoteOptions identifies a raw note to promote and its wiki destination.
|
||||||
|
type PromoteOptions struct {
|
||||||
|
Filename string // basename in brain/raw/
|
||||||
|
Wing string
|
||||||
|
Hall string
|
||||||
|
Slug string // optional; defaults to Filename minus date prefix + .md
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
|
||||||
|
// rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at and
|
||||||
|
// any custom fields), writes to brain/wiki/<wing>/<hall>/<slug>.md, deletes
|
||||||
|
// the source, then rebuilds the wing index and runs auto-tunnel detection.
|
||||||
|
//
|
||||||
|
// It is atomic from the caller's view: validation (hall, wing, slug,
|
||||||
|
// collision) happens before any filesystem change, and the source is deleted
|
||||||
|
// only after the destination write succeeds (write-then-delete, never move).
|
||||||
|
// Returns the promoted note's path relative to brainDir.
|
||||||
|
func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
||||||
|
// Validate filename (basename only — no traversal) before touching fs.
|
||||||
|
base := filepath.Base(opts.Filename)
|
||||||
|
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
|
||||||
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := opts.Slug
|
||||||
|
if slug == "" {
|
||||||
|
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
||||||
|
}
|
||||||
|
// NotePath validates hall + wing + slug; do this before reading anything.
|
||||||
|
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
src := filepath.Join(brainDir, "raw", base)
|
||||||
|
raw, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("read source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collision: never silently overwrite an existing promoted note.
|
||||||
|
if _, statErr := os.Stat(dest); statErr == nil {
|
||||||
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
return "", fmt.Errorf("target %s already exists; choose a different slug", filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
fm, body := parseFrontmatter(string(raw))
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
fm.set("wing", brain.Sanitise(opts.Wing))
|
||||||
|
fm.set("hall", opts.Hall)
|
||||||
|
if fm.get("created_at") == "" {
|
||||||
|
fm.set("created_at", now)
|
||||||
|
}
|
||||||
|
fm.set("promoted_at", now)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create wing dir: %w", err)
|
||||||
|
}
|
||||||
|
// Write-then-delete: the source survives any write failure.
|
||||||
|
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write promoted note: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(src); err != nil {
|
||||||
|
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
relSlash := filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
// Best-effort wiki upkeep — the note is already promoted.
|
||||||
|
_ = brain.BuildWingIndex(brainDir, opts.Wing)
|
||||||
|
_ = brain.AutoTunnel(brainDir, relSlash, body)
|
||||||
|
|
||||||
|
return relSlash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// excerpt returns the first n runes of s, trimmed, single-spaced.
|
||||||
|
func excerpt(s string, n int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) > n {
|
||||||
|
r = r[:n]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(r))
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeRaw(t *testing.T, brainDir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPendingEmptyWhenAbsent(t *testing.T) {
|
||||||
|
got, err := ListPending(t.TempDir())
|
||||||
|
require.NoError(t, err, "absent raw/ is not an error")
|
||||||
|
assert.Empty(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPendingReturnsOldestFirstWithExcerpt(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-02-newer.md", "---\ncreated_at: 2026-06-02T00:00:00Z\n---\nNewer body here.\n")
|
||||||
|
writeRaw(t, dir, "2026-06-01-older.md", "---\ncreated_at: 2026-06-01T00:00:00Z\n---\nOlder body content.\n")
|
||||||
|
// non-md ignored
|
||||||
|
writeRaw(t, dir, "notes.txt", "ignore me")
|
||||||
|
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
assert.Equal(t, "2026-06-01-older.md", got[0].Filename, "oldest first")
|
||||||
|
assert.Equal(t, "2026-06-02-newer.md", got[1].Filename)
|
||||||
|
assert.Contains(t, got[0].Excerpt, "Older body content")
|
||||||
|
assert.NotContains(t, got[0].Excerpt, "---", "excerpt is body, not frontmatter")
|
||||||
|
assert.Positive(t, got[0].SizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteHappyPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-lejpa-decision.md",
|
||||||
|
"---\ncreated_at: 2026-06-01T09:00:00Z\ncustom_field: keep-me\n---\n# LeJEPA\n\nbody.\n")
|
||||||
|
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{
|
||||||
|
Filename: "2026-06-01-lejpa-decision.md", Wing: "jepa-fx", Hall: "decisions",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/jepa-fx/decisions/lejpa-decision.md", rel, "slug defaults to filename minus date prefix")
|
||||||
|
|
||||||
|
// Source deleted.
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-lejpa-decision.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "source removed after promote")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "wing: jepa-fx")
|
||||||
|
assert.Contains(t, s, "hall: decisions")
|
||||||
|
assert.Contains(t, s, "created_at: 2026-06-01T09:00:00Z", "original created_at preserved")
|
||||||
|
assert.Contains(t, s, "promoted_at:")
|
||||||
|
assert.Contains(t, s, "custom_field: keep-me", "custom frontmatter preserved")
|
||||||
|
assert.Contains(t, s, "# LeJEPA")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteExplicitSlug(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts", Slug: "custom-slug"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/a/facts/custom-slug.md", rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteInvalidHallErrorsBeforeTouchingFS(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "garbage"})
|
||||||
|
require.Error(t, err)
|
||||||
|
// Source untouched.
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-x.md"))
|
||||||
|
assert.NoError(t, statErr, "invalid hall must not delete or move the source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteMissingSourceErrors(t *testing.T) {
|
||||||
|
_, err := PromoteNote(t.TempDir(), PromoteOptions{Filename: "ghost.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteSlugCollisionNoOverwrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Pre-existing target.
|
||||||
|
dest := filepath.Join(dir, "wiki", "a", "facts", "x.md")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(dest, []byte("EXISTING\n"), 0o644))
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "NEW\n")
|
||||||
|
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err, "collision must error, not overwrite")
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(dest)
|
||||||
|
assert.Equal(t, "EXISTING\n", string(got), "target not overwritten")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-x.md"))
|
||||||
|
assert.NoError(t, statErr, "source preserved on collision (atomic: no delete without write)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteRejectsTraversalFilename(t *testing.T) {
|
||||||
|
_, err := PromoteNote(t.TempDir(), PromoteOptions{Filename: "../escape.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteRebuildsWingIndex(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "---\ntitle: X Note\n---\nbody\n")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
idx, err := os.ReadFile(filepath.Join(dir, "wiki", "a", "_index.md"))
|
||||||
|
require.NoError(t, err, "wing _index regenerated")
|
||||||
|
assert.Contains(t, string(idx), "x", "promoted note appears in the index")
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileBuffer is a durable, restart-surviving audit buffer backed by a
|
||||||
|
// JSONL file: one {id, entry} record per line. It is the internal/public
|
||||||
|
// tier fallback when loki is unreachable. Confirm rewrites the file
|
||||||
|
// without the confirmed record, so a record is cleared only after its
|
||||||
|
// central write is confirmed.
|
||||||
|
//
|
||||||
|
// Access is serialised by a mutex; the buffer is low-throughput (only
|
||||||
|
// written during a loki outage), so a whole-file rewrite on Confirm is
|
||||||
|
// acceptable and keeps the on-disk format trivially correct.
|
||||||
|
type FileBuffer struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type bufferLine struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Entry capture.AuditEntry `json:"entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileBuffer returns a buffer backed by path. The parent directory is
|
||||||
|
// created if needed. The file itself is created lazily on first Append.
|
||||||
|
func NewFileBuffer(path string) (*FileBuffer, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create buffer dir: %w", err)
|
||||||
|
}
|
||||||
|
return &FileBuffer{path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writable reports whether the buffer file can be appended to. It probes
|
||||||
|
// by opening the file for append (creating it if absent) — the same
|
||||||
|
// operation Append performs — so Reserve's check matches Append's reality.
|
||||||
|
func (b *FileBuffer) Writable() error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
f, err := os.OpenFile(b.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append durably writes one audit record. The ID is derived from the
|
||||||
|
// content + timestamp so it is stable and unique per record.
|
||||||
|
func (b *FileBuffer) Append(e capture.AuditEntry) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
line := bufferLine{ID: recordID(e), Entry: e}
|
||||||
|
data, err := json.Marshal(line)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal buffer line: %w", err)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(b.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if _, err := f.Write(append(data, '\n')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending reads all buffered records. A missing file means none.
|
||||||
|
func (b *FileBuffer) Pending() ([]Buffered, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.readAllLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *FileBuffer) readAllLocked() ([]Buffered, error) {
|
||||||
|
f, err := os.Open(b.path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
var out []Buffered
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
raw := sc.Bytes()
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var l bufferLine
|
||||||
|
if err := json.Unmarshal(raw, &l); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse buffer line: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, Buffered(l))
|
||||||
|
}
|
||||||
|
return out, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm removes a single record after its central write is confirmed, by
|
||||||
|
// rewriting the file without it. Unknown IDs are a no-op.
|
||||||
|
func (b *FileBuffer) Confirm(id string) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
all, err := b.readAllLocked()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := b.path + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := bufio.NewWriter(f)
|
||||||
|
kept := 0
|
||||||
|
for _, rec := range all {
|
||||||
|
if rec.ID == id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(bufferLine(rec))
|
||||||
|
if _, err := w.Write(append(data, '\n')); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept++
|
||||||
|
}
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Empty buffer → remove the file entirely so Pending sees nothing.
|
||||||
|
if kept == 0 {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return os.Remove(b.path)
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, b.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordID is a stable per-record identifier: sha256 of the principal,
|
||||||
|
// timestamp, and item list. Distinct captures never collide; the same
|
||||||
|
// buffered record always hashes the same.
|
||||||
|
func recordID(e capture.AuditEntry) string {
|
||||||
|
h := sha256.New()
|
||||||
|
_, _ = fmt.Fprintf(h, "%s|%s|%v|%s", e.Principal, e.Timestamp.UTC().Format("2006-01-02T15:04:05.000000000Z07:00"), e.Items, e.SessionRef)
|
||||||
|
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Central is the central audit substrate (loki). Ready is a cheap
|
||||||
|
// reachability probe used by the pre-write reserve; Push writes a record.
|
||||||
|
type Central interface {
|
||||||
|
Ready(ctx context.Context) error
|
||||||
|
Push(ctx context.Context, e capture.AuditEntry) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer is the durable local fallback for internal/public-tier records
|
||||||
|
// when the central sink is unreachable. It must survive process restart.
|
||||||
|
type Buffer interface {
|
||||||
|
// Writable reports whether the buffer can currently be appended to.
|
||||||
|
Writable() error
|
||||||
|
Append(e capture.AuditEntry) error
|
||||||
|
// Pending returns buffered records awaiting reconciliation, each with a
|
||||||
|
// stable ID used to Confirm (delete) it after a confirmed central write.
|
||||||
|
Pending() ([]Buffered, error)
|
||||||
|
Confirm(id string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffered is a buffered audit record plus its stable buffer ID.
|
||||||
|
type Buffered struct {
|
||||||
|
ID string
|
||||||
|
Entry capture.AuditEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier raises an out-of-band alert (ntfy) about a degraded state.
|
||||||
|
type Notifier interface {
|
||||||
|
Notify(ctx context.Context, msg string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DegradingSink is the classification-aware AuditSink (§4.4):
|
||||||
|
//
|
||||||
|
// - central reachable → AuditCentral (all tiers).
|
||||||
|
// - central down + confidential → refuse (no buffer): confidential must
|
||||||
|
// be centrally auditable at write time.
|
||||||
|
// - central down + internal/public + buffer writable → AuditBuffered.
|
||||||
|
// - central down + (confidential, or buffer not writable) → refuse (floor).
|
||||||
|
//
|
||||||
|
// The decision is made in Reserve, before any write; Record then executes it.
|
||||||
|
type DegradingSink struct {
|
||||||
|
central Central
|
||||||
|
buffer Buffer
|
||||||
|
notifier Notifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDegradingSink wires the central sink, durable buffer, and notifier.
|
||||||
|
func NewDegradingSink(central Central, buffer Buffer, notifier Notifier) *DegradingSink {
|
||||||
|
return &DegradingSink{central: central, buffer: buffer, notifier: notifier}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve decides, before any write, how the capture will be audited — or
|
||||||
|
// returns an error to refuse it.
|
||||||
|
func (d *DegradingSink) Reserve(ctx context.Context, level classification.Level) (capture.AuditOutcome, error) {
|
||||||
|
if err := d.central.Ready(ctx); err == nil {
|
||||||
|
return capture.AuditCentral, nil
|
||||||
|
}
|
||||||
|
// Central sink is down.
|
||||||
|
if level == classification.Confidential {
|
||||||
|
return 0, fmt.Errorf("confidential capture requires the central audit sink, which is unreachable")
|
||||||
|
}
|
||||||
|
if err := d.buffer.Writable(); err != nil {
|
||||||
|
// Floor: neither central nor local buffer can record the audit.
|
||||||
|
return 0, fmt.Errorf("audit floor: central sink down and local buffer unwritable: %w", err)
|
||||||
|
}
|
||||||
|
return capture.AuditBuffered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record persists the entry per the reserved outcome. For AuditBuffered it
|
||||||
|
// also fires the degraded-state alert.
|
||||||
|
func (d *DegradingSink) Record(ctx context.Context, e capture.AuditEntry, outcome capture.AuditOutcome) error {
|
||||||
|
switch outcome {
|
||||||
|
case capture.AuditBuffered:
|
||||||
|
if err := d.buffer.Append(e); err != nil {
|
||||||
|
return fmt.Errorf("buffer audit record: %w", err)
|
||||||
|
}
|
||||||
|
// Best-effort alert; the record is already durably buffered.
|
||||||
|
if d.notifier != nil {
|
||||||
|
_ = d.notifier.Notify(ctx, fmt.Sprintf(
|
||||||
|
"capture audit BUFFERED LOCALLY (loki unreachable) — principal=%s class=%s items=%d",
|
||||||
|
e.Principal, e.EffectiveClassification, len(e.Items)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return d.central.Push(ctx, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package audit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- fakes ---
|
||||||
|
|
||||||
|
type fakeCentral struct {
|
||||||
|
down bool
|
||||||
|
pushed []capture.AuditEntry
|
||||||
|
pushErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCentral) Ready(context.Context) error {
|
||||||
|
if f.down {
|
||||||
|
return errors.New("loki down")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCentral) Push(_ context.Context, e capture.AuditEntry) error {
|
||||||
|
if f.pushErr != nil {
|
||||||
|
return f.pushErr
|
||||||
|
}
|
||||||
|
f.pushed = append(f.pushed, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeNotifier struct{ msgs []string }
|
||||||
|
|
||||||
|
func (f *fakeNotifier) Notify(_ context.Context, msg string) error {
|
||||||
|
f.msgs = append(f.msgs, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unwritableBuffer always reports it cannot be written (floor condition).
|
||||||
|
type unwritableBuffer struct{}
|
||||||
|
|
||||||
|
func (unwritableBuffer) Writable() error { return errors.New("disk full") }
|
||||||
|
func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") }
|
||||||
|
func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil }
|
||||||
|
func (unwritableBuffer) Confirm(string) error { return nil }
|
||||||
|
|
||||||
|
func newFileBuffer(t *testing.T) *audit.FileBuffer {
|
||||||
|
t.Helper()
|
||||||
|
b, err := audit.NewFileBuffer(filepath.Join(t.TempDir(), "audit-buffer.jsonl"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func entry(principal string) capture.AuditEntry {
|
||||||
|
return capture.AuditEntry{Principal: principal, EffectiveClassification: "internal", Items: []string{"insight:x"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reserve: classification-aware decision ---
|
||||||
|
|
||||||
|
func TestReserveCentralUpGrantsCentral(t *testing.T) {
|
||||||
|
d := audit.NewDegradingSink(&fakeCentral{}, newFileBuffer(t), &fakeNotifier{})
|
||||||
|
for _, lvl := range []classification.Level{classification.Public, classification.Internal, classification.Confidential} {
|
||||||
|
out, err := d.Reserve(context.Background(), lvl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, capture.AuditCentral, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveConfidentialSinkDownRefuses(t *testing.T) {
|
||||||
|
d := audit.NewDegradingSink(&fakeCentral{down: true}, newFileBuffer(t), &fakeNotifier{})
|
||||||
|
_, err := d.Reserve(context.Background(), classification.Confidential)
|
||||||
|
require.Error(t, err, "confidential + sink down → refuse, no buffer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveInternalSinkDownBuffers(t *testing.T) {
|
||||||
|
d := audit.NewDegradingSink(&fakeCentral{down: true}, newFileBuffer(t), &fakeNotifier{})
|
||||||
|
out, err := d.Reserve(context.Background(), classification.Internal)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, capture.AuditBuffered, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveFloorRefusesWhenNothingCanRecord(t *testing.T) {
|
||||||
|
d := audit.NewDegradingSink(&fakeCentral{down: true}, unwritableBuffer{}, &fakeNotifier{})
|
||||||
|
_, err := d.Reserve(context.Background(), classification.Internal)
|
||||||
|
require.Error(t, err, "central down AND buffer unwritable → floor refuse")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Record: executes the reserved outcome ---
|
||||||
|
|
||||||
|
func TestRecordCentralPushes(t *testing.T) {
|
||||||
|
c := &fakeCentral{}
|
||||||
|
d := audit.NewDegradingSink(c, newFileBuffer(t), &fakeNotifier{})
|
||||||
|
require.NoError(t, d.Record(context.Background(), entry("p"), capture.AuditCentral))
|
||||||
|
assert.Len(t, c.pushed, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordBufferedAppendsAndNotifies(t *testing.T) {
|
||||||
|
buf := newFileBuffer(t)
|
||||||
|
nt := &fakeNotifier{}
|
||||||
|
d := audit.NewDegradingSink(&fakeCentral{down: true}, buf, nt)
|
||||||
|
require.NoError(t, d.Record(context.Background(), entry("p"), capture.AuditBuffered))
|
||||||
|
|
||||||
|
pending, err := buf.Pending()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, pending, 1)
|
||||||
|
assert.NotEmpty(t, nt.msgs, "degraded state alerts via ntfy")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FileBuffer durability + Confirm ---
|
||||||
|
|
||||||
|
func TestFileBufferSurvivesRestart(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "buf.jsonl")
|
||||||
|
b1, err := audit.NewFileBuffer(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, b1.Append(entry("p1")))
|
||||||
|
require.NoError(t, b1.Append(entry("p2")))
|
||||||
|
|
||||||
|
// "restart": a fresh FileBuffer over the same file sees the records.
|
||||||
|
b2, err := audit.NewFileBuffer(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
pending, err := b2.Pending()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, pending, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileBufferConfirmRemovesOnlyThatRecord(t *testing.T) {
|
||||||
|
buf := newFileBuffer(t)
|
||||||
|
require.NoError(t, buf.Append(entry("keep")))
|
||||||
|
require.NoError(t, buf.Append(entry("drop")))
|
||||||
|
|
||||||
|
pending, _ := buf.Pending()
|
||||||
|
require.Len(t, pending, 2)
|
||||||
|
var dropID string
|
||||||
|
for _, p := range pending {
|
||||||
|
if p.Entry.Principal == "drop" {
|
||||||
|
dropID = p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NoError(t, buf.Confirm(dropID))
|
||||||
|
|
||||||
|
after, _ := buf.Pending()
|
||||||
|
require.Len(t, after, 1)
|
||||||
|
assert.Equal(t, "keep", after[0].Entry.Principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reconcile ---
|
||||||
|
|
||||||
|
func TestReconcileReplaysAndClearsOnlyAfterConfirmedWrite(t *testing.T) {
|
||||||
|
buf := newFileBuffer(t)
|
||||||
|
require.NoError(t, buf.Append(entry("a")))
|
||||||
|
require.NoError(t, buf.Append(entry("b")))
|
||||||
|
c := &fakeCentral{} // up
|
||||||
|
nt := &fakeNotifier{}
|
||||||
|
|
||||||
|
n, err := audit.Reconcile(context.Background(), c, buf, nt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, n)
|
||||||
|
assert.Len(t, c.pushed, 2, "buffered records replayed to central")
|
||||||
|
|
||||||
|
pending, _ := buf.Pending()
|
||||||
|
assert.Empty(t, pending, "buffer cleared after confirmed central writes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileNoopWhenCentralDown(t *testing.T) {
|
||||||
|
buf := newFileBuffer(t)
|
||||||
|
require.NoError(t, buf.Append(entry("a")))
|
||||||
|
n, err := audit.Reconcile(context.Background(), &fakeCentral{down: true}, buf, &fakeNotifier{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, n)
|
||||||
|
pending, _ := buf.Pending()
|
||||||
|
assert.Len(t, pending, 1, "records stay buffered while central is down")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileKeepsRecordWhenPushFails(t *testing.T) {
|
||||||
|
buf := newFileBuffer(t)
|
||||||
|
require.NoError(t, buf.Append(entry("a")))
|
||||||
|
// Ready ok but Push fails → record must remain buffered (not lost).
|
||||||
|
c := &fakeCentral{pushErr: errors.New("push rejected")}
|
||||||
|
n, err := audit.Reconcile(context.Background(), c, buf, &fakeNotifier{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, n)
|
||||||
|
pending, _ := buf.Pending()
|
||||||
|
assert.Len(t, pending, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LokiCentral pushes capture audit records to a Grafana Loki instance via
|
||||||
|
// its push API, and probes readiness via /ready. It is the central audit
|
||||||
|
// substrate behind DegradingSink.
|
||||||
|
type LokiCentral struct {
|
||||||
|
baseURL string
|
||||||
|
labels map[string]string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLokiCentral constructs a LokiCentral for the given base URL (e.g.
|
||||||
|
// http://loki:3100). Returns nil when baseURL is empty so callers can
|
||||||
|
// treat missing config as "no central sink" with a single nil check.
|
||||||
|
func NewLokiCentral(baseURL string) *LokiCentral {
|
||||||
|
if baseURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &LokiCentral{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
labels: map[string]string{"service": "brain-capture", "kind": "audit"},
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready probes Loki's readiness endpoint.
|
||||||
|
func (l *LokiCentral) Ready(ctx context.Context) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.baseURL+"/ready", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := l.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loki not ready: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("loki not ready: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushPayload is the Loki push API body: one stream, one entry whose line
|
||||||
|
// is the JSON-encoded audit record.
|
||||||
|
type pushPayload struct {
|
||||||
|
Streams []lokiStream `json:"streams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lokiStream struct {
|
||||||
|
Stream map[string]string `json:"stream"`
|
||||||
|
Values [][2]string `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push writes one audit record to Loki as a structured log line.
|
||||||
|
func (l *LokiCentral) Push(ctx context.Context, e capture.AuditEntry) error {
|
||||||
|
line, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal audit entry: %w", err)
|
||||||
|
}
|
||||||
|
ts := e.Timestamp
|
||||||
|
if ts.IsZero() {
|
||||||
|
ts = time.Now()
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(pushPayload{Streams: []lokiStream{{
|
||||||
|
Stream: l.labels,
|
||||||
|
Values: [][2]string{{strconv.FormatInt(ts.UTC().UnixNano(), 10), string(line)}},
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
l.baseURL+"/loki/api/v1/push", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := l.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loki push: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("loki push: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package audit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLokiReadyAndPush(t *testing.T) {
|
||||||
|
var pushBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/ready":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case "/loki/api/v1/push":
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
pushBody = string(b)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := audit.NewLokiCentral(srv.URL)
|
||||||
|
require.NotNil(t, c)
|
||||||
|
require.NoError(t, c.Ready(context.Background()))
|
||||||
|
|
||||||
|
err := c.Push(context.Background(), capture.AuditEntry{
|
||||||
|
Principal: "koala-cli", EffectiveClassification: "internal", Items: []string{"insight:x"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, pushBody, "streams")
|
||||||
|
assert.Contains(t, pushBody, "koala-cli", "audit entry serialised into the loki line")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLokiReadyFailsWhenDown(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
require.Error(t, audit.NewLokiCentral(srv.URL).Ready(context.Background()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLokiNilWhenUnconfigured(t *testing.T) {
|
||||||
|
assert.Nil(t, audit.NewLokiCentral(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNtfyNotify(t *testing.T) {
|
||||||
|
var gotBody, gotAuth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
gotBody = string(b)
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
n := audit.NewNtfyNotifier(srv.URL, "ntfy-token")
|
||||||
|
require.NotNil(t, n)
|
||||||
|
require.NoError(t, n.Notify(context.Background(), "audit buffered locally"))
|
||||||
|
assert.Contains(t, gotBody, "audit buffered locally")
|
||||||
|
assert.Equal(t, "Bearer ntfy-token", gotAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNtfyDoesNotLeakTokenOnError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
err := audit.NewNtfyNotifier(srv.URL, "secret-token").Notify(context.Background(), "x")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, strings.Contains(err.Error(), "secret-token"), "token must not leak into errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNtfyNilWhenUnconfigured(t *testing.T) {
|
||||||
|
assert.Nil(t, audit.NewNtfyNotifier("", "tok"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NtfyNotifier posts alerts to an ntfy topic URL. Used to surface a
|
||||||
|
// degraded audit state (records buffered locally during a loki outage).
|
||||||
|
type NtfyNotifier struct {
|
||||||
|
topicURL string
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNtfyNotifier constructs a notifier for the given ntfy topic URL
|
||||||
|
// (e.g. https://ntfy.sh/my-topic). token is an optional bearer for
|
||||||
|
// protected ntfy instances; it is held here and only sent in the
|
||||||
|
// Authorization header, never logged. Returns nil when topicURL is empty.
|
||||||
|
func NewNtfyNotifier(topicURL, token string) *NtfyNotifier {
|
||||||
|
if topicURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &NtfyNotifier{
|
||||||
|
topicURL: strings.TrimRight(topicURL, "/"),
|
||||||
|
token: token,
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify posts a message to the ntfy topic.
|
||||||
|
func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Title", "brain-capture audit degraded")
|
||||||
|
req.Header.Set("Priority", "high")
|
||||||
|
req.Header.Set("Tags", "warning,brain")
|
||||||
|
if n.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+n.token)
|
||||||
|
}
|
||||||
|
resp, err := n.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntfy notify: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("ntfy notify: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reconcile replays locally-buffered audit records to the central sink
|
||||||
|
// when it is reachable again. A record is removed from the buffer ONLY
|
||||||
|
// after its central write is confirmed, so a crash mid-reconcile re-plays
|
||||||
|
// rather than loses. Returns the number of records reconciled.
|
||||||
|
//
|
||||||
|
// A no-op (0, nil) when the central sink is still unreachable or the
|
||||||
|
// buffer is empty.
|
||||||
|
func Reconcile(ctx context.Context, central Central, buffer Buffer, notifier Notifier) (int, error) {
|
||||||
|
if err := central.Ready(ctx); err != nil {
|
||||||
|
return 0, nil // still down; try again next tick
|
||||||
|
}
|
||||||
|
pending, err := buffer.Pending()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read buffer: %w", err)
|
||||||
|
}
|
||||||
|
reconciled := 0
|
||||||
|
for _, rec := range pending {
|
||||||
|
if err := central.Push(ctx, rec.Entry); err != nil {
|
||||||
|
// Central went away mid-drain; stop and keep the rest buffered.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := buffer.Confirm(rec.ID); err != nil {
|
||||||
|
return reconciled, fmt.Errorf("confirm buffered record %s: %w", rec.ID, err)
|
||||||
|
}
|
||||||
|
reconciled++
|
||||||
|
}
|
||||||
|
if reconciled > 0 && notifier != nil {
|
||||||
|
_ = notifier.Notify(ctx, fmt.Sprintf("reconciled %d buffered capture audit record(s) to loki", reconciled))
|
||||||
|
}
|
||||||
|
return reconciled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartReconcile runs Reconcile on a ticker until ctx is cancelled. It is
|
||||||
|
// the recovery half of the degrade-and-buffer path; pair it with a
|
||||||
|
// DegradingSink sharing the same buffer + central.
|
||||||
|
func StartReconcile(ctx context.Context, central Central, buffer Buffer, notifier Notifier, interval time.Duration) {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = time.Minute
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if n, err := Reconcile(ctx, central, buffer, notifier); err != nil {
|
||||||
|
slog.Warn("audit reconcile failed", "err", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
slog.Info("audit reconcile", "reconciled", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Package audit provides AuditSink implementations for the capture
|
||||||
|
// capability (I5). This file ships the minimal slog-backed sink used in
|
||||||
|
// #53: it emits the request-level audit record to structured logs, which
|
||||||
|
// the alloy/loki substrate already scrapes. The classification-aware
|
||||||
|
// degradation/refusal sink (confidential fails closed, internal buffers +
|
||||||
|
// reconciles) lands in #54 and replaces this behind the same interface.
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SlogSink records audit entries to an slog.Logger. It never fails and is
|
||||||
|
// always centrally available, so its Reserve always grants AuditCentral —
|
||||||
|
// it does not exercise the I5 degradation/floor. That is DegradingSink's
|
||||||
|
// job (loki + durable buffer). SlogSink is the default for deployments
|
||||||
|
// without a loki endpoint configured. A nil logger ⇒ slog.Default().
|
||||||
|
type SlogSink struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSlogSink constructs a SlogSink. nil logger ⇒ slog.Default().
|
||||||
|
func NewSlogSink(logger *slog.Logger) *SlogSink {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &SlogSink{logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve always grants central recording — slog is always available.
|
||||||
|
func (s *SlogSink) Reserve(_ context.Context, _ classification.Level) (capture.AuditOutcome, error) {
|
||||||
|
return capture.AuditCentral, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record emits the audit entry at info level. Security events, when
|
||||||
|
// present, are logged at warn level so they surface independently of the
|
||||||
|
// routine audit stream.
|
||||||
|
func (s *SlogSink) Record(_ context.Context, e capture.AuditEntry, _ capture.AuditOutcome) error {
|
||||||
|
s.logger.Info("capture audit",
|
||||||
|
"principal", e.Principal,
|
||||||
|
"actor", e.Actor,
|
||||||
|
"harness", e.Harness,
|
||||||
|
"session_ref", e.SessionRef,
|
||||||
|
"classification", e.EffectiveClassification,
|
||||||
|
"items", e.Items,
|
||||||
|
"ts", e.Timestamp,
|
||||||
|
)
|
||||||
|
for _, ev := range e.SecurityEvents {
|
||||||
|
s.logger.Warn("capture security event", "principal", e.Principal, "event", ev)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package audit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSlogSinkRecordsEntryAndSecurityEvents(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
sink := audit.NewSlogSink(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||||
|
|
||||||
|
err := sink.Record(context.Background(), capture.AuditEntry{
|
||||||
|
Principal: "koala-cli",
|
||||||
|
Harness: "claude-code",
|
||||||
|
EffectiveClassification: "confidential",
|
||||||
|
Items: []string{"insight:wiki/a/facts/x.md"},
|
||||||
|
SecurityEvents: []string{"asserted-vs-derived origin mismatch"},
|
||||||
|
}, capture.AuditCentral)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
assert.Contains(t, out, "capture audit")
|
||||||
|
assert.Contains(t, out, "koala-cli")
|
||||||
|
assert.Contains(t, out, "confidential")
|
||||||
|
assert.Contains(t, out, "capture security event")
|
||||||
|
assert.Contains(t, out, "asserted-vs-derived origin mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlogSinkNilLoggerDefaults(t *testing.T) {
|
||||||
|
// nil logger must not panic.
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
_ = audit.NewSlogSink(nil).Record(context.Background(), capture.AuditEntry{}, capture.AuditCentral)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package brainstore is the concrete BrainStore: the single shared
|
||||||
|
// implementation of the #45 write/update/get verbs, used by BOTH the MCP
|
||||||
|
// handlers and the capture use-case so there is one implementation, not
|
||||||
|
// two (the Clean-Architecture / DRY payoff of #51).
|
||||||
|
//
|
||||||
|
// It composes the file-level primitives in package api (WriteNote,
|
||||||
|
// UpdateNote, ReadNote — the read-after-write contract) with the wiki
|
||||||
|
// upkeep that must accompany a write: wing _index rebuild, cross-wing
|
||||||
|
// auto-tunnel, and graph re-index. Embedding refresh is intentionally
|
||||||
|
// out-of-band (mtime-driven vectorstore.Sync) and not triggered here —
|
||||||
|
// see the brain note on out-of-band sync.
|
||||||
|
package brainstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store implements capture.BrainStore against a brain directory on disk,
|
||||||
|
// optionally re-indexing each write into the knowledge graph.
|
||||||
|
type Store struct {
|
||||||
|
brainDir string
|
||||||
|
graph graphsync.Store // nil = graph re-index disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Store bound to brainDir with graph indexing disabled.
|
||||||
|
func New(brainDir string) *Store {
|
||||||
|
return &Store{brainDir: brainDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithGraph enables graph re-index on every write/update. nil disables it.
|
||||||
|
func (s *Store) WithGraph(g graphsync.Store) *Store {
|
||||||
|
s.graph = g
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write creates a brain note and returns its read-after-write handle.
|
||||||
|
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
|
||||||
|
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||||
|
Content: n.Content,
|
||||||
|
Filename: n.Filename,
|
||||||
|
Type: n.Type,
|
||||||
|
Domain: n.Domain,
|
||||||
|
Wing: n.Wing,
|
||||||
|
Hall: n.Hall,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
s.wikiUpkeep(relPath, n.Wing, n.Content)
|
||||||
|
s.indexInGraph(ctx, "brain_write", relPath)
|
||||||
|
|
||||||
|
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||||
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update supersedes an existing note in place. slug may be a bare slug
|
||||||
|
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
|
||||||
|
// contains a slash). It never creates — a missing target is an error.
|
||||||
|
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
|
||||||
|
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
||||||
|
if strings.Contains(slug, "/") {
|
||||||
|
opts.Path = slug
|
||||||
|
} else {
|
||||||
|
opts.Wing, opts.Hall, opts.Slug = n.Wing, n.Hall, slug
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, hash, _, err := api.UpdateNote(s.brainDir, opts)
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
if wing := wingFromRelPath(relPath); wing != "" {
|
||||||
|
s.wikiUpkeep(relPath, wing, n.Content)
|
||||||
|
}
|
||||||
|
s.indexInGraph(ctx, "brain_update", relPath)
|
||||||
|
|
||||||
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fetches a note by id/path — the read-after-write confirmation
|
||||||
|
// primitive (a direct fetch, never a semantic query).
|
||||||
|
func (s *Store) Get(_ context.Context, id string) (capture.StoredNote, error) {
|
||||||
|
fm, body, hash, err := api.ReadNote(s.brainDir, id)
|
||||||
|
if err != nil {
|
||||||
|
return capture.StoredNote{}, err
|
||||||
|
}
|
||||||
|
return capture.StoredNote{ID: id, Path: id, ContentHash: hash, Frontmatter: fm, Body: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wikiUpkeep rebuilds the wing _index and re-tunnels cross-wing matches
|
||||||
|
// when a note lands in the structured wiki. Both are best-effort: the
|
||||||
|
// note is already written, so a failure here is logged, not propagated.
|
||||||
|
func (s *Store) wikiUpkeep(relPath, wing, content string) {
|
||||||
|
if wing == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := brain.BuildWingIndex(s.brainDir, wing); err != nil {
|
||||||
|
slog.Warn("brainstore: auto-index failed", "wing", wing, "err", err)
|
||||||
|
}
|
||||||
|
if err := brain.AutoTunnel(s.brainDir, relPath, content); err != nil {
|
||||||
|
slog.Warn("brainstore: auto-tunnel failed", "src", relPath, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexInGraph re-indexes a written doc into the graph, best-effort.
|
||||||
|
func (s *Store) indexInGraph(ctx context.Context, op, relPath string) {
|
||||||
|
if s.graph == nil || relPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := graphsync.IndexDoc(ctx, s.graph, s.brainDir, relPath); err != nil {
|
||||||
|
slog.Warn(op+": graph index failed", "path", relPath, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wingFromRelPath extracts the wing from a structured wiki path
|
||||||
|
// (wiki/<wing>/<hall>/<slug>.md). Returns "" for legacy/non-wiki paths.
|
||||||
|
func wingFromRelPath(relPath string) string {
|
||||||
|
parts := strings.Split(relPath, "/")
|
||||||
|
if len(parts) >= 4 && parts[0] == "wiki" {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package brainstore_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreWriteReturnsHandle(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
|
||||||
|
ref, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "# X\n\nbody\n", Filename: "x", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/a/facts/x.md", ref.Path)
|
||||||
|
assert.Equal(t, ref.Path, ref.ID)
|
||||||
|
assert.NotEmpty(t, ref.ContentHash)
|
||||||
|
assert.False(t, ref.Superseded)
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(dir, "wiki/a/facts/x.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateSupersedes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Update(context.Background(), "n", capture.Note{
|
||||||
|
Content: "new\n", Wing: "a", Hall: "facts", Reason: "changed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, ref.Superseded)
|
||||||
|
assert.Equal(t, "wiki/a/facts/n.md", ref.Path)
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md"))
|
||||||
|
assert.Contains(t, string(got), "new")
|
||||||
|
assert.Contains(t, string(got), "supersede_reason: changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateByFullPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{Content: "old\n", Filename: "n", Wing: "a", Hall: "facts"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Update(context.Background(), "wiki/a/facts/n.md", capture.Note{Content: "fresh\n"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/a/facts/n.md", ref.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateMissingErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Update(context.Background(), "ghost", capture.Note{Content: "x\n", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/a/facts/ghost.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "update must not create")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreGetRoundTripsHash(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
ref, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "# Body\n\ntext\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
note, err := s.Get(context.Background(), ref.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ref.ContentHash, note.ContentHash, "write→get hash round-trips")
|
||||||
|
assert.Equal(t, "a", note.Frontmatter["wing"])
|
||||||
|
assert.Contains(t, note.Body, "# Body")
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package capture is the Clean-Architecture use-case for the uniform
|
||||||
|
// capture capability (issue #49/#51): persist a finished session's
|
||||||
|
// valuable output — insights → brain, action items → Gitea tickets,
|
||||||
|
// optional summary → ai-sessions — with one invocation, identical core
|
||||||
|
// behaviour across every harness.
|
||||||
|
//
|
||||||
|
// This package is pure orchestration. It depends only on ports
|
||||||
|
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
|
||||||
|
// or audit I/O. The real adapters are wired in #52 (Gitea tracker), #53
|
||||||
|
// (REST + I1 origin gate), and #54/#55 (audit path + relay). The I1
|
||||||
|
// sovereignty refusal and the classification-aware audit degradation are
|
||||||
|
// deliberately NOT here — those need the server-derived principal origin
|
||||||
|
// (#53) and the loki/buffer machinery (#54). What lives here is everything
|
||||||
|
// testable against fakes: validation, effective-classification resolution
|
||||||
|
// (stricter wins), best-effort orchestration, and the partial receipt.
|
||||||
|
package capture
|
||||||
|
|
||||||
|
// Zone is the trust zone a capture originates from, server-derived from
|
||||||
|
// the authenticated principal (spec §4.2 / I1). It is NEVER taken from
|
||||||
|
// caller input — context.Harness is descriptive telemetry only.
|
||||||
|
type Zone int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ZoneUnknown means the origin was not set. The REST adapter always
|
||||||
|
// sets a concrete zone; the service treats Unknown as "not gated" (only
|
||||||
|
// an explicit ZoneUSNexus triggers the I1 refusal) so the gate can
|
||||||
|
// never fire on a caller-controllable default.
|
||||||
|
ZoneUnknown Zone = iota
|
||||||
|
// ZoneSovereign is sovereign soil (homelab / Tailscale CLI callers).
|
||||||
|
ZoneSovereign
|
||||||
|
// ZoneUSNexus is a non-sovereign US-jurisdiction surface (e.g.
|
||||||
|
// claude.ai). Confidential captures through it are refused (I1).
|
||||||
|
ZoneUSNexus
|
||||||
|
)
|
||||||
|
|
||||||
|
// String renders the zone for audit/refusal messages.
|
||||||
|
func (z Zone) String() string {
|
||||||
|
switch z {
|
||||||
|
case ZoneSovereign:
|
||||||
|
return "sovereign-soil"
|
||||||
|
case ZoneUSNexus:
|
||||||
|
return "us-nexus"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureContext is the per-session metadata accompanying a capture.
|
||||||
|
//
|
||||||
|
// Classification is the caller-declared sensitivity (model C, spec §4.1):
|
||||||
|
// the server independently derives the target's classification and gates
|
||||||
|
// on the stricter of the two. Principal and Origin are server-derived from
|
||||||
|
// the authenticated identity (the REST adapter populates them); they are
|
||||||
|
// never caller-asserted. Harness is descriptive telemetry only — never a
|
||||||
|
// gate input.
|
||||||
|
type CaptureContext struct {
|
||||||
|
Harness string
|
||||||
|
SessionRef string
|
||||||
|
Fidelity string
|
||||||
|
Actor string
|
||||||
|
Classification string // caller-declared level token ("" = unspecified)
|
||||||
|
Principal string // server-derived (auth); audit identity
|
||||||
|
Origin Zone // server-derived trust zone; the I1 gate input
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insight is one piece of session knowledge bound for the brain. A
|
||||||
|
// non-empty SupersedeSlug routes to Update (revise in place); otherwise
|
||||||
|
// Write (create).
|
||||||
|
type Insight struct {
|
||||||
|
Text string
|
||||||
|
Wing string
|
||||||
|
Hall string
|
||||||
|
SupersedeSlug string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket is one action item bound for a Gitea repo. Owner is always the
|
||||||
|
// operator (set by the tracker adapter), never carried here.
|
||||||
|
type Ticket struct {
|
||||||
|
Repo string
|
||||||
|
Action string // create | close | comment
|
||||||
|
Number int // required for close/comment
|
||||||
|
Title string // required for create
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary is an optional session summary bound for ai-sessions.
|
||||||
|
type Summary struct {
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
ReposTouched []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureInput is the whole capture request.
|
||||||
|
type CaptureInput struct {
|
||||||
|
Context CaptureContext
|
||||||
|
Insights []Insight
|
||||||
|
Tickets []Ticket
|
||||||
|
Summary *Summary
|
||||||
|
DryRun bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsightResult is the per-insight outcome in the receipt.
|
||||||
|
type InsightResult struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
ContentHash string `json:"content_hash,omitempty"`
|
||||||
|
Superseded bool `json:"superseded"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicketResult is the per-ticket outcome in the receipt.
|
||||||
|
type TicketResult struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummaryResult is the summary outcome in the receipt.
|
||||||
|
type SummaryResult struct {
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemError pins a failure to a specific request item for the partial
|
||||||
|
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
|
||||||
|
type ItemError struct {
|
||||||
|
Item string `json:"item"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureReceipt is the structured, partial-aware result. Per-item ok
|
||||||
|
// flags plus a flat Errors list make partial success explicit; the
|
||||||
|
// caller never has to infer what landed.
|
||||||
|
type CaptureReceipt struct {
|
||||||
|
Insights []InsightResult `json:"insights"`
|
||||||
|
Tickets []TicketResult `json:"tickets"`
|
||||||
|
Summary *SummaryResult `json:"summary,omitempty"`
|
||||||
|
Errors []ItemError `json:"errors"`
|
||||||
|
EffectiveClassification string `json:"effective_classification,omitempty"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
// AuditBuffered is true when the central audit sink was unreachable and
|
||||||
|
// this capture's audit record was written to the durable local buffer
|
||||||
|
// instead (internal/public tier). Surfaces the degraded state to the
|
||||||
|
// caller per §4.4.
|
||||||
|
AuditBuffered bool `json:"audit_buffered,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ref is the read-after-write handle returned by a brain write/update —
|
||||||
|
// the #45 contract. ContentHash lets the caller confirm what landed
|
||||||
|
// without a re-query; for an Update, Superseded is true.
|
||||||
|
type Ref struct {
|
||||||
|
ID string
|
||||||
|
Path string
|
||||||
|
ContentHash string
|
||||||
|
Superseded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoredNote is a brain note fetched by Get: the read-after-write
|
||||||
|
// confirmation primitive (a direct fetch, never a semantic query).
|
||||||
|
type StoredNote struct {
|
||||||
|
ID string
|
||||||
|
Path string
|
||||||
|
ContentHash string
|
||||||
|
Frontmatter map[string]string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note is the brain-write payload. It carries both the wing/hall taxonomy
|
||||||
|
// and the legacy type/domain fields so a single BrainStore serves both
|
||||||
|
// capture insights and the existing MCP brain_write surface. Reason is
|
||||||
|
// the supersede rationale, used only by Update.
|
||||||
|
type Note struct {
|
||||||
|
Content string
|
||||||
|
Filename string
|
||||||
|
Wing string
|
||||||
|
Hall string
|
||||||
|
Type string
|
||||||
|
Domain string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrainStore is the brain persistence port — the shared implementation of
|
||||||
|
// the #45 write/update/get verbs that both the MCP handlers and capture
|
||||||
|
// call, so there is one implementation, not two. The read-after-write +
|
||||||
|
// staleness discipline lives behind this interface so no caller carries
|
||||||
|
// the rule.
|
||||||
|
type BrainStore interface {
|
||||||
|
Write(ctx context.Context, n Note) (Ref, error)
|
||||||
|
Update(ctx context.Context, slug string, n Note) (Ref, error)
|
||||||
|
Get(ctx context.Context, id string) (StoredNote, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueRef identifies a ticket touched by the tracker.
|
||||||
|
type IssueRef struct {
|
||||||
|
Repo string
|
||||||
|
Number int
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueTracker is the Gitea ticket port. The implementation (#52) always
|
||||||
|
// scopes to owner "mathias"; the port deliberately omits owner.
|
||||||
|
type IssueTracker interface {
|
||||||
|
CreateIssue(ctx context.Context, repo, title, body string) (IssueRef, error)
|
||||||
|
// CloseIssue closes an issue, optionally posting a closing comment
|
||||||
|
// first (empty comment ⇒ close only).
|
||||||
|
CloseIssue(ctx context.Context, repo string, number int, comment string) (IssueRef, error)
|
||||||
|
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummaryWriter is the ai-sessions summary port.
|
||||||
|
type SummaryWriter interface {
|
||||||
|
WriteFile(ctx context.Context, repo, path, content string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassificationPolicy derives a target's sensitivity (model C). The
|
||||||
|
// "stricter wins" combination of declared vs derived is use-case policy
|
||||||
|
// and lives in the service, so the port stays minimal. Satisfied by
|
||||||
|
// classification.Config (#50).
|
||||||
|
type ClassificationPolicy interface {
|
||||||
|
Derive(target classification.Target) classification.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditEntry is the request-level audit record (I5): who/what captured
|
||||||
|
// what, when, via which principal. SecurityEvents carries anomalies such
|
||||||
|
// as a caller under-declaring sensitivity relative to the target floor.
|
||||||
|
type AuditEntry struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Principal string
|
||||||
|
Actor string
|
||||||
|
Harness string
|
||||||
|
SessionRef string
|
||||||
|
EffectiveClassification string
|
||||||
|
Items []string
|
||||||
|
SecurityEvents []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditOutcome is how a capture's audit record was (or will be) persisted.
|
||||||
|
type AuditOutcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AuditCentral means the record goes to the central sink (loki).
|
||||||
|
AuditCentral AuditOutcome = iota
|
||||||
|
// AuditBuffered means the central sink was unreachable and the record
|
||||||
|
// is written to a durable local buffer for later reconciliation
|
||||||
|
// (internal/public tier only).
|
||||||
|
AuditBuffered
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditSink is the two-phase, classification-aware audit port (I5, §4.4).
|
||||||
|
//
|
||||||
|
// Reserve runs BEFORE any write and decides whether the capture can be
|
||||||
|
// audited at its effective classification: it returns the outcome to use,
|
||||||
|
// or an error to refuse the capture before anything is written
|
||||||
|
// (confidential + central sink down → refuse; the all-tiers floor when
|
||||||
|
// nothing can record → refuse). Record runs AFTER the writes and persists
|
||||||
|
// the final entry per the reserved outcome.
|
||||||
|
//
|
||||||
|
// Splitting reserve from record is what lets "confidential + sink-down →
|
||||||
|
// refuse before any write" be literally true while the record itself
|
||||||
|
// (which lists what landed) is necessarily written afterwards.
|
||||||
|
type AuditSink interface {
|
||||||
|
Reserve(ctx context.Context, level classification.Level) (AuditOutcome, error)
|
||||||
|
Record(ctx context.Context, e AuditEntry, outcome AuditOutcome) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service is the CaptureSession use-case. It depends only on ports.
|
||||||
|
type Service struct {
|
||||||
|
brain BrainStore
|
||||||
|
issues IssueTracker
|
||||||
|
summaries SummaryWriter
|
||||||
|
policy ClassificationPolicy
|
||||||
|
audit AuditSink
|
||||||
|
|
||||||
|
// now is the clock, injectable for deterministic summary paths and
|
||||||
|
// audit timestamps in tests.
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs a Service from its ports. summaries may be nil
|
||||||
|
// when no summary persistence is wired; a CaptureInput with a Summary
|
||||||
|
// then fails that item rather than panicking.
|
||||||
|
func NewService(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
|
return &Service{brain: b, issues: tr, summaries: sw, policy: p, audit: a, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
||||||
|
|
||||||
|
// ErrSovereigntyRefused is returned when the I1 gate refuses a capture
|
||||||
|
// (confidential effective classification through a us-nexus origin). The
|
||||||
|
// REST adapter maps it to HTTP 403. Callers test with errors.Is.
|
||||||
|
var ErrSovereigntyRefused = fmt.Errorf("capture refused by I1 sovereignty gate")
|
||||||
|
|
||||||
|
// ErrAuditUnavailable is returned when the I5 audit gate refuses a capture
|
||||||
|
// before any write: a confidential capture whose central audit sink is
|
||||||
|
// unreachable, or the all-tiers floor where nothing can record the audit.
|
||||||
|
// The REST adapter maps it to HTTP 503. Callers test with errors.Is.
|
||||||
|
var ErrAuditUnavailable = fmt.Errorf("capture refused: audit substrate unavailable")
|
||||||
|
|
||||||
|
// assertedZoneMismatch returns a security-event string when the caller's
|
||||||
|
// harness label asserts a trust zone that contradicts the server-derived
|
||||||
|
// origin. A harness label that names no zone (the normal case, e.g.
|
||||||
|
// "claude-code") returns "". The label is never used as a gate input —
|
||||||
|
// this only flags the discrepancy for the audit trail.
|
||||||
|
func assertedZoneMismatch(harness string, derived Zone) string {
|
||||||
|
var asserted Zone
|
||||||
|
switch strings.ToLower(strings.TrimSpace(harness)) {
|
||||||
|
case "sovereign-soil", "sovereign":
|
||||||
|
asserted = ZoneSovereign
|
||||||
|
case "us-nexus", "usnexus":
|
||||||
|
asserted = ZoneUSNexus
|
||||||
|
default:
|
||||||
|
return "" // no zone claim
|
||||||
|
}
|
||||||
|
if asserted != derived {
|
||||||
|
return fmt.Sprintf("asserted-vs-derived origin mismatch: harness asserted %s, principal resolves to %s",
|
||||||
|
asserted, derived)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture runs the use-case: validate (fail-closed), resolve effective
|
||||||
|
// classification (stricter of declared vs target-derived), then persist
|
||||||
|
// insights → tickets → summary best-effort, emit an audit record, and
|
||||||
|
// return a partial-aware receipt.
|
||||||
|
//
|
||||||
|
// A validation failure returns a non-nil error with nothing written. A
|
||||||
|
// per-item execution failure is recorded in the receipt (no rollback);
|
||||||
|
// the call still returns a nil error so the caller gets the partial
|
||||||
|
// receipt. The I1 origin gate and audit-down degradation are layered on
|
||||||
|
// by #53/#54 around this core.
|
||||||
|
func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt, error) {
|
||||||
|
if err := s.validate(in); err != nil {
|
||||||
|
return CaptureReceipt{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
declared := classification.Public // unspecified ⇒ lowest ⇒ target floor governs
|
||||||
|
if in.Context.Classification != "" {
|
||||||
|
// Already validated parseable.
|
||||||
|
declared, _ = classification.ParseLevel(in.Context.Classification)
|
||||||
|
}
|
||||||
|
|
||||||
|
effective, securityEvents := s.resolveClassification(declared, in)
|
||||||
|
|
||||||
|
// Server-derived origin governs the I1 gate; a caller-asserted harness
|
||||||
|
// label that names a different zone is descriptive-only and logged as a
|
||||||
|
// security event (spec §4.2: a control keyed on attacker-suppliable
|
||||||
|
// input is not a control).
|
||||||
|
if ev := assertedZoneMismatch(in.Context.Harness, in.Context.Origin); ev != "" {
|
||||||
|
securityEvents = append(securityEvents, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// I1 sovereignty gate: a confidential capture through a us-nexus origin
|
||||||
|
// is refused before ANY write. The refusal itself is audited (best
|
||||||
|
// effort) — refusals must be reconstructable too.
|
||||||
|
if effective == classification.Confidential && in.Context.Origin == ZoneUSNexus {
|
||||||
|
_ = s.audit.Record(ctx, AuditEntry{
|
||||||
|
Timestamp: s.now().UTC(),
|
||||||
|
Principal: in.Context.Principal,
|
||||||
|
Actor: in.Context.Actor,
|
||||||
|
Harness: in.Context.Harness,
|
||||||
|
SessionRef: in.Context.SessionRef,
|
||||||
|
EffectiveClassification: effective.String(),
|
||||||
|
Items: nil, // refused before any write
|
||||||
|
SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"),
|
||||||
|
}, AuditCentral)
|
||||||
|
return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin",
|
||||||
|
ErrSovereigntyRefused, in.Context.Origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt := CaptureReceipt{
|
||||||
|
Errors: []ItemError{},
|
||||||
|
EffectiveClassification: effective.String(),
|
||||||
|
DryRun: in.DryRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.DryRun {
|
||||||
|
// Would-be receipt: mark planned items ok, write nothing (not even
|
||||||
|
// audit — dry_run touches nothing).
|
||||||
|
for range in.Insights {
|
||||||
|
receipt.Insights = append(receipt.Insights, InsightResult{OK: true})
|
||||||
|
}
|
||||||
|
for _, tk := range in.Tickets {
|
||||||
|
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
|
||||||
|
}
|
||||||
|
if in.Summary != nil {
|
||||||
|
receipt.Summary = &SummaryResult{Path: s.summaryPath(in.Context, in.Summary), OK: true}
|
||||||
|
}
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// I5 audit gate: decide BEFORE any write whether this capture can be
|
||||||
|
// audited at its effective classification. Confidential + central sink
|
||||||
|
// down → refuse here, before writing anything; the all-tiers floor
|
||||||
|
// (nothing can record) likewise refuses. Internal/public degrade to the
|
||||||
|
// durable local buffer (signalled by AuditBuffered).
|
||||||
|
outcome, err := s.audit.Reserve(ctx, effective)
|
||||||
|
if err != nil {
|
||||||
|
return CaptureReceipt{}, fmt.Errorf("%w: %v", ErrAuditUnavailable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var landed []string
|
||||||
|
|
||||||
|
for i, ins := range in.Insights {
|
||||||
|
res, item, err := s.persistInsight(ctx, ins)
|
||||||
|
receipt.Insights = append(receipt.Insights, res)
|
||||||
|
if err != nil {
|
||||||
|
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
landed = append(landed, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tk := range in.Tickets {
|
||||||
|
res, err := s.persistTicket(ctx, tk)
|
||||||
|
receipt.Tickets = append(receipt.Tickets, res)
|
||||||
|
if err != nil {
|
||||||
|
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("ticket[%d]", i), Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Summary != nil {
|
||||||
|
res, err := s.persistSummary(ctx, in.Context, in.Summary)
|
||||||
|
receipt.Summary = &res
|
||||||
|
if err != nil {
|
||||||
|
receipt.Errors = append(receipt.Errors, ItemError{Item: "summary", Error: err.Error()})
|
||||||
|
} else {
|
||||||
|
landed = append(landed, "summary:"+res.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// I5: persist the request-level audit record of exactly what landed,
|
||||||
|
// using the outcome reserved before the writes. AuditBuffered surfaces
|
||||||
|
// the degraded (locally-buffered) state on the receipt.
|
||||||
|
if err := s.audit.Record(ctx, AuditEntry{
|
||||||
|
Timestamp: s.now().UTC(),
|
||||||
|
Principal: in.Context.Principal,
|
||||||
|
Actor: in.Context.Actor,
|
||||||
|
Harness: in.Context.Harness,
|
||||||
|
SessionRef: in.Context.SessionRef,
|
||||||
|
EffectiveClassification: effective.String(),
|
||||||
|
Items: landed,
|
||||||
|
SecurityEvents: securityEvents,
|
||||||
|
}, outcome); err != nil {
|
||||||
|
receipt.Errors = append(receipt.Errors, ItemError{Item: "audit", Error: err.Error()})
|
||||||
|
}
|
||||||
|
if outcome == AuditBuffered {
|
||||||
|
receipt.AuditBuffered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate enforces fail-closed structural validity over the whole
|
||||||
|
// request before any write. A bad declared classification, an invalid
|
||||||
|
// wing/hall, an empty insight, or a malformed ticket aborts the capture
|
||||||
|
// with nothing written.
|
||||||
|
func (s *Service) validate(in CaptureInput) error {
|
||||||
|
if in.Context.Classification != "" {
|
||||||
|
if _, err := classification.ParseLevel(in.Context.Classification); err != nil {
|
||||||
|
return fmt.Errorf("context.classification: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, ins := range in.Insights {
|
||||||
|
if strings.TrimSpace(ins.Text) == "" {
|
||||||
|
return fmt.Errorf("insight[%d]: text is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ins.Wing) == "" {
|
||||||
|
return fmt.Errorf("insight[%d]: wing is required", i)
|
||||||
|
}
|
||||||
|
if !brain.IsValidHall(ins.Hall) {
|
||||||
|
return fmt.Errorf("insight[%d]: invalid hall %q", i, ins.Hall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, tk := range in.Tickets {
|
||||||
|
if strings.TrimSpace(tk.Repo) == "" {
|
||||||
|
return fmt.Errorf("ticket[%d]: repo is required", i)
|
||||||
|
}
|
||||||
|
if !validActions[tk.Action] {
|
||||||
|
return fmt.Errorf("ticket[%d]: invalid action %q (want create/close/comment)", i, tk.Action)
|
||||||
|
}
|
||||||
|
if tk.Action == "create" && strings.TrimSpace(tk.Title) == "" {
|
||||||
|
return fmt.Errorf("ticket[%d]: create requires a title", i)
|
||||||
|
}
|
||||||
|
if (tk.Action == "close" || tk.Action == "comment") && tk.Number <= 0 {
|
||||||
|
return fmt.Errorf("ticket[%d]: %s requires an issue number", i, tk.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveClassification computes the effective level (stricter of
|
||||||
|
// declared and every target's derived level) and collects a security
|
||||||
|
// event whenever the caller under-declared relative to a target floor.
|
||||||
|
func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string) {
|
||||||
|
effective := declared
|
||||||
|
var events []string
|
||||||
|
consider := func(kind classification.TargetKind, name string) {
|
||||||
|
derived := s.policy.Derive(classification.Target{Kind: kind, Name: name})
|
||||||
|
effective = classification.Stricter(effective, derived)
|
||||||
|
if declared < derived {
|
||||||
|
events = append(events, fmt.Sprintf("classification under-declared: declared=%s target=%s(%s) derived=%s",
|
||||||
|
declared, name, kindString(kind), derived))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ins := range in.Insights {
|
||||||
|
consider(classification.WingTarget, ins.Wing)
|
||||||
|
}
|
||||||
|
for _, tk := range in.Tickets {
|
||||||
|
consider(classification.RepoTarget, tk.Repo)
|
||||||
|
}
|
||||||
|
if in.Summary != nil {
|
||||||
|
for _, repo := range in.Summary.ReposTouched {
|
||||||
|
consider(classification.RepoTarget, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return effective, events
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
|
||||||
|
note := Note{Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text))}
|
||||||
|
var ref Ref
|
||||||
|
var err error
|
||||||
|
if ins.SupersedeSlug != "" {
|
||||||
|
note.Reason = "superseded via capture"
|
||||||
|
ref, err = s.brain.Update(ctx, ins.SupersedeSlug, note)
|
||||||
|
} else {
|
||||||
|
ref, err = s.brain.Write(ctx, note)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return InsightResult{OK: false, Superseded: ins.SupersedeSlug != ""}, "", err
|
||||||
|
}
|
||||||
|
return InsightResult{
|
||||||
|
ID: ref.ID, Path: ref.Path, ContentHash: ref.ContentHash,
|
||||||
|
Superseded: ref.Superseded, OK: true,
|
||||||
|
}, "insight:" + ref.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, error) {
|
||||||
|
res := TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number}
|
||||||
|
var ref IssueRef
|
||||||
|
var err error
|
||||||
|
switch tk.Action {
|
||||||
|
case "create":
|
||||||
|
ref, err = s.issues.CreateIssue(ctx, tk.Repo, tk.Title, tk.Body)
|
||||||
|
case "close":
|
||||||
|
ref, err = s.issues.CloseIssue(ctx, tk.Repo, tk.Number, tk.Body)
|
||||||
|
case "comment":
|
||||||
|
ref, err = s.issues.CommentIssue(ctx, tk.Repo, tk.Number, tk.Body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if ref.Number != 0 {
|
||||||
|
res.Number = ref.Number
|
||||||
|
}
|
||||||
|
res.URL = ref.URL
|
||||||
|
res.OK = true
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) persistSummary(ctx context.Context, c CaptureContext, sum *Summary) (SummaryResult, error) {
|
||||||
|
if s.summaries == nil {
|
||||||
|
return SummaryResult{OK: false}, fmt.Errorf("no summary writer configured")
|
||||||
|
}
|
||||||
|
path := s.summaryPath(c, sum)
|
||||||
|
content := s.renderSummary(c, sum)
|
||||||
|
repo := "ai-sessions"
|
||||||
|
if err := s.summaries.WriteFile(ctx, repo, path, content); err != nil {
|
||||||
|
return SummaryResult{Path: path, OK: false}, err
|
||||||
|
}
|
||||||
|
return SummaryResult{Path: path, OK: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// summaryPath builds summaries/<harness>/<YYYY-MM>/<date>-<slug>-<ref8>.md.
|
||||||
|
// The ref8 disambiguator is derived from the session_ref (or the title
|
||||||
|
// when no ref is present) so distinct sessions never collide.
|
||||||
|
func (s *Service) summaryPath(c CaptureContext, sum *Summary) string {
|
||||||
|
t := s.now().UTC()
|
||||||
|
slug := brain.Sanitise(sum.Title)
|
||||||
|
if slug == "" {
|
||||||
|
slug = "summary"
|
||||||
|
}
|
||||||
|
seed := c.SessionRef
|
||||||
|
if seed == "" {
|
||||||
|
seed = sum.Title + sum.Body
|
||||||
|
}
|
||||||
|
sum8 := shortHash(seed)
|
||||||
|
return fmt.Sprintf("summaries/%s/%s/%s-%s-%s.md",
|
||||||
|
brain.Sanitise(c.Harness), t.Format("2006-01"), t.Format("2006-01-02"), slug, sum8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSummary stamps fidelity + session metadata into frontmatter so the
|
||||||
|
// richer-fidelity-supersedes-thinner collision rule has the data it needs.
|
||||||
|
func (s *Service) renderSummary(c CaptureContext, sum *Summary) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("---\n")
|
||||||
|
fmt.Fprintf(&b, "title: %s\n", sum.Title)
|
||||||
|
fmt.Fprintf(&b, "harness: %s\n", c.Harness)
|
||||||
|
if c.SessionRef != "" {
|
||||||
|
fmt.Fprintf(&b, "session_ref: %s\n", c.SessionRef)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "fidelity: %s\n", c.Fidelity)
|
||||||
|
fmt.Fprintf(&b, "captured_at: %s\n", s.now().UTC().Format(time.RFC3339))
|
||||||
|
if len(sum.ReposTouched) > 0 {
|
||||||
|
fmt.Fprintf(&b, "repos_touched: [%s]\n", strings.Join(sum.ReposTouched, ", "))
|
||||||
|
}
|
||||||
|
b.WriteString("---\n\n")
|
||||||
|
b.WriteString(sum.Body)
|
||||||
|
if !strings.HasSuffix(sum.Body, "\n") {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func kindString(k classification.TargetKind) string {
|
||||||
|
if k == classification.RepoTarget {
|
||||||
|
return "repo"
|
||||||
|
}
|
||||||
|
return "wing"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
s = strings.TrimLeft(s, "# ")
|
||||||
|
if len(s) > 60 {
|
||||||
|
s = s[:60]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortHash(s string) string {
|
||||||
|
sum := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(sum[:])[:8]
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- fakes ---
|
||||||
|
|
||||||
|
type fakeBrain struct {
|
||||||
|
writes []Note
|
||||||
|
updates []Note
|
||||||
|
gets []string
|
||||||
|
failOn func(Note) error // nil = always succeed
|
||||||
|
hashSeq int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBrain) ref(prefix string, n Note, superseded bool) Ref {
|
||||||
|
f.hashSeq++
|
||||||
|
path := "wiki/" + n.Wing + "/" + n.Hall + "/" + n.Filename + ".md"
|
||||||
|
return Ref{ID: path, Path: path, ContentHash: prefix + string(rune('0'+f.hashSeq)), Superseded: superseded}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBrain) Write(_ context.Context, n Note) (Ref, error) {
|
||||||
|
if f.failOn != nil {
|
||||||
|
if err := f.failOn(n); err != nil {
|
||||||
|
return Ref{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.writes = append(f.writes, n)
|
||||||
|
return f.ref("w", n, false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBrain) Update(_ context.Context, slug string, n Note) (Ref, error) {
|
||||||
|
if f.failOn != nil {
|
||||||
|
if err := f.failOn(n); err != nil {
|
||||||
|
return Ref{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n.Filename = slug
|
||||||
|
f.updates = append(f.updates, n)
|
||||||
|
return f.ref("u", n, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBrain) Get(_ context.Context, id string) (StoredNote, error) {
|
||||||
|
f.gets = append(f.gets, id)
|
||||||
|
return StoredNote{ID: id, Path: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTracker struct {
|
||||||
|
created []string
|
||||||
|
closed []int
|
||||||
|
comments []int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return IssueRef{}, f.err
|
||||||
|
}
|
||||||
|
f.created = append(f.created, repo+":"+title)
|
||||||
|
return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTracker) CloseIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return IssueRef{}, f.err
|
||||||
|
}
|
||||||
|
f.closed = append(f.closed, number)
|
||||||
|
return IssueRef{Repo: repo, Number: number}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return IssueRef{}, f.err
|
||||||
|
}
|
||||||
|
f.comments = append(f.comments, number)
|
||||||
|
return IssueRef{Repo: repo, Number: number}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeSummary struct {
|
||||||
|
paths []string
|
||||||
|
content []string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSummary) WriteFile(_ context.Context, _, path, content string) error {
|
||||||
|
if f.err != nil {
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
f.paths = append(f.paths, path)
|
||||||
|
f.content = append(f.content, content)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakePolicy derives from an explicit map; default Internal so tests pin
|
||||||
|
// behaviour without depending on the real defaulting.
|
||||||
|
type fakePolicy struct{ tags map[string]classification.Level }
|
||||||
|
|
||||||
|
func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
||||||
|
if lvl, ok := p.tags[t.Name]; ok {
|
||||||
|
return lvl
|
||||||
|
}
|
||||||
|
return classification.Internal
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAudit struct {
|
||||||
|
entries []AuditEntry
|
||||||
|
err error // Record error
|
||||||
|
reserveErr error // Reserve error (refuse before write)
|
||||||
|
reserveMode AuditOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAudit) Reserve(_ context.Context, _ classification.Level) (AuditOutcome, error) {
|
||||||
|
if f.reserveErr != nil {
|
||||||
|
return 0, f.reserveErr
|
||||||
|
}
|
||||||
|
return f.reserveMode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) error {
|
||||||
|
if f.err != nil {
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
f.entries = append(f.entries, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
|
s := NewService(b, tr, sw, p, a)
|
||||||
|
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseCtx() CaptureContext {
|
||||||
|
return CaptureContext{Harness: "claude-code", Actor: "mathias", Principal: "mathias", Classification: "internal"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scenarios ---
|
||||||
|
|
||||||
|
func TestCaptureHappyPath(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{
|
||||||
|
{Text: "a", Wing: "hyperguild", Hall: "decisions", SupersedeSlug: ""},
|
||||||
|
{Text: "b", Wing: "hyperguild", Hall: "facts"},
|
||||||
|
},
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "do x", Body: "y"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rec.Insights, 2)
|
||||||
|
for _, r := range rec.Insights {
|
||||||
|
assert.True(t, r.OK)
|
||||||
|
assert.NotEmpty(t, r.ContentHash, "read-after-write hash returned")
|
||||||
|
}
|
||||||
|
require.Len(t, rec.Tickets, 1)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
assert.Equal(t, 2, len(b.writes))
|
||||||
|
assert.Empty(t, rec.Errors)
|
||||||
|
// Audit emitted naming principal/harness + items that landed.
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
assert.Equal(t, "mathias", au.entries[0].Principal)
|
||||||
|
assert.Equal(t, "claude-code", au.entries[0].Harness)
|
||||||
|
assert.Len(t, au.entries[0].Items, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{{Text: "revised", Wing: "hyperguild", Hall: "facts", SupersedeSlug: "prior-note"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, b.writes, "supersede must not create")
|
||||||
|
require.Len(t, b.updates, 1)
|
||||||
|
assert.Equal(t, "prior-note", b.updates[0].Filename)
|
||||||
|
assert.True(t, rec.Insights[0].Superseded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureValidationFailClosed(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||||
|
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{
|
||||||
|
{Text: "ok", Wing: "hyperguild", Hall: "facts"},
|
||||||
|
{Text: "bad", Wing: "hyperguild", Hall: "garbage-hall"}, // invalid hall
|
||||||
|
},
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
// Nothing written anywhere.
|
||||||
|
assert.Empty(t, b.writes)
|
||||||
|
assert.Empty(t, b.updates)
|
||||||
|
assert.Empty(t, tr.created)
|
||||||
|
assert.Empty(t, au.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "close"}}, // close needs number
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapturePartialFailureBestEffort(t *testing.T) {
|
||||||
|
b := &fakeBrain{failOn: func(n Note) error {
|
||||||
|
if strings.Contains(n.Content, "FAIL") {
|
||||||
|
return errors.New("disk full")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{
|
||||||
|
{Text: "good one", Wing: "hyperguild", Hall: "facts"},
|
||||||
|
{Text: "FAIL here", Wing: "hyperguild", Hall: "facts"},
|
||||||
|
},
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "partial failure is not a request-level error")
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
assert.False(t, rec.Insights[1].OK)
|
||||||
|
assert.True(t, rec.Tickets[0].OK, "ticket still persisted; no rollback")
|
||||||
|
require.Len(t, rec.Errors, 1)
|
||||||
|
assert.Equal(t, "insight[1]", rec.Errors[0].Item)
|
||||||
|
// Audit reflects exactly what landed: 1 insight + 1 ticket.
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
assert.Len(t, au.entries[0].Items, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureDryRunWritesNothing(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
DryRun: true,
|
||||||
|
Insights: []Insight{{Text: "a", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rec.DryRun)
|
||||||
|
assert.Len(t, rec.Insights, 1)
|
||||||
|
assert.True(t, rec.Insights[0].OK, "would-be receipt marks planned items ok")
|
||||||
|
// Nothing written anywhere, including audit.
|
||||||
|
assert.Empty(t, b.writes)
|
||||||
|
assert.Empty(t, tr.created)
|
||||||
|
assert.Empty(t, au.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureStricterClassificationWins(t *testing.T) {
|
||||||
|
// Caller declares internal; target wing tagged confidential → effective confidential + security event.
|
||||||
|
b := &fakeBrain{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, nil, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Classification = "internal"
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "confidential", rec.EffectiveClassification)
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
assert.NotEmpty(t, au.entries[0].SecurityEvents, "under-declaration logged as security event")
|
||||||
|
assert.Equal(t, "confidential", au.entries[0].EffectiveClassification)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
||||||
|
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
||||||
|
au := &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Classification = "confidential"
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "confidential", rec.EffectiveClassification)
|
||||||
|
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureSummaryPathAndFidelity(t *testing.T) {
|
||||||
|
sw := &fakeSummary{}
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, sw, fakePolicy{}, &fakeAudit{})
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Fidelity = "transcript-parse"
|
||||||
|
ctx.SessionRef = "abc123def456"
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Summary: &Summary{Title: "Session Wrap", Body: "did stuff", ReposTouched: []string{"hyperguild"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, rec.Summary)
|
||||||
|
assert.True(t, rec.Summary.OK)
|
||||||
|
require.Len(t, sw.paths, 1)
|
||||||
|
assert.True(t, strings.HasPrefix(sw.paths[0], "summaries/claude-code/2026-06/"), "path: %s", sw.paths[0])
|
||||||
|
assert.Contains(t, sw.paths[0], "session-wrap")
|
||||||
|
assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- I1 sovereignty gate (#53) ---
|
||||||
|
|
||||||
|
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
|
svc := newSvc(b, tr, nil, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Classification = "confidential"
|
||||||
|
ctx.Origin = ZoneUSNexus
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||||
|
// Refused before any write.
|
||||||
|
assert.Empty(t, b.writes)
|
||||||
|
assert.Empty(t, tr.created)
|
||||||
|
// Refusal is audited.
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
assert.Empty(t, au.entries[0].Items, "no items landed on refusal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, nil, pol, &fakeAudit{})
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Classification = "confidential"
|
||||||
|
ctx.Origin = ZoneSovereign
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
assert.Len(t, b.writes, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
|
||||||
|
// Caller asserts harness "sovereign-soil" but principal resolves to
|
||||||
|
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
|
||||||
|
au := &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Harness = "sovereign-soil" // asserted
|
||||||
|
ctx.Origin = ZoneUSNexus // server-derived
|
||||||
|
ctx.Classification = "confidential"
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
joined := strings.Join(au.entries[0].SecurityEvents, " | ")
|
||||||
|
assert.Contains(t, joined, "asserted-vs-derived origin mismatch")
|
||||||
|
assert.Contains(t, joined, "I1 refusal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
||||||
|
// us-nexus origin is fine for non-confidential data.
|
||||||
|
b := &fakeBrain{}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- I5 audit gate (#54) ---
|
||||||
|
|
||||||
|
func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) {
|
||||||
|
// Reserve refusing (e.g. confidential + central sink down, or the floor)
|
||||||
|
// aborts the capture before any write.
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
||||||
|
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||||
|
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrAuditUnavailable)
|
||||||
|
assert.Empty(t, b.writes, "nothing written when audit unavailable")
|
||||||
|
assert.Empty(t, tr.created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
||||||
|
// Reserve returns AuditBuffered (internal/public, central down) → capture
|
||||||
|
// proceeds and the receipt flags the degraded audit state.
|
||||||
|
b := &fakeBrain{}
|
||||||
|
au := &fakeAudit{reserveMode: AuditBuffered}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rec.Insights[0].OK, "capture proceeds on degraded audit")
|
||||||
|
assert.True(t, rec.AuditBuffered, "receipt flags locally-buffered audit")
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
||||||
|
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
||||||
|
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
DryRun: true,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "dry-run does not hit the audit gate")
|
||||||
|
assert.True(t, rec.DryRun)
|
||||||
|
assert.Empty(t, au.entries)
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// Package capturehttp is the REST adapter for the capture use-case: the
|
||||||
|
// POST /capture door (#53). It is deliberately thin — authenticate, derive
|
||||||
|
// the trust-zone origin from the authenticated principal, decode the
|
||||||
|
// request, call capture.Service, map the receipt to an HTTP status. No
|
||||||
|
// business logic lives here; the I1 gate, validation, and orchestration
|
||||||
|
// are all in the use-case.
|
||||||
|
package capturehttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validator validates a Bearer JWT and returns its subject. The chassis
|
||||||
|
// *auth.JWTValidator satisfies it (including its nil-receiver "disabled"
|
||||||
|
// behaviour), and tests can substitute a fake without a live JWKS.
|
||||||
|
type Validator interface {
|
||||||
|
Validate(ctx context.Context, rawToken string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves POST /capture.
|
||||||
|
type Handler struct {
|
||||||
|
svc *capture.Service
|
||||||
|
validator Validator // nil ⇒ JWT auth disabled
|
||||||
|
staticToken string // "" ⇒ static auth disabled
|
||||||
|
staticPrincipal string // principal name attributed to static-token callers
|
||||||
|
resolver OriginResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a capture HTTP handler. staticToken callers are
|
||||||
|
// attributed to staticPrincipal (a sovereign homelab identity); JWT
|
||||||
|
// callers are attributed to their token subject.
|
||||||
|
func New(svc *capture.Service, validator Validator, staticToken, staticPrincipal string, resolver OriginResolver) *Handler {
|
||||||
|
if staticPrincipal == "" {
|
||||||
|
staticPrincipal = "local-cli"
|
||||||
|
}
|
||||||
|
return &Handler{
|
||||||
|
svc: svc,
|
||||||
|
validator: validator,
|
||||||
|
staticToken: staticToken,
|
||||||
|
staticPrincipal: staticPrincipal,
|
||||||
|
resolver: resolver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wire types — the POST /capture request body.
|
||||||
|
type request struct {
|
||||||
|
Context contextBody `json:"context"`
|
||||||
|
Insights []insightBody `json:"insights"`
|
||||||
|
Tickets []ticketBody `json:"tickets"`
|
||||||
|
Summary *summaryBody `json:"summary,omitempty"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextBody struct {
|
||||||
|
Harness string `json:"harness"`
|
||||||
|
SessionRef string `json:"session_ref"`
|
||||||
|
Fidelity string `json:"fidelity"`
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
Classification string `json:"classification"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type insightBody struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Wing string `json:"wing"`
|
||||||
|
Hall string `json:"hall"`
|
||||||
|
SupersedeSlug string `json:"supersede_slug,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ticketBody struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type summaryBody struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
ReposTouched []string `json:"repos_touched,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
||||||
|
// result to an HTTP status.
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, viaStatic, ok := Authenticate(r, h.staticToken, h.staticPrincipal, h.validator)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, err := DecodeRequest(body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Principal and origin are server-derived — overwrite anything the
|
||||||
|
// caller may have tried to put in the body.
|
||||||
|
in.Context.Principal = principal
|
||||||
|
in.Context.Origin = h.resolver.Resolve(principal, viaStatic)
|
||||||
|
|
||||||
|
rec, err := h.svc.Capture(r.Context(), in)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, capture.ErrSovereigntyRefused):
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
case errors.Is(err, capture.ErrAuditUnavailable):
|
||||||
|
// I5 refusal: confidential + audit sink down, or the all-tiers floor.
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
// Pre-write validation failure (fail-closed).
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, statusFor(rec), rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate mirrors the chassis Bearer precedence (static token wins,
|
||||||
|
// then Dex JWT) and returns the resolved principal plus whether the static
|
||||||
|
// path was taken — the chassis middleware hides both, and capture (REST or
|
||||||
|
// MCP) needs them to derive the trust-zone origin. ok is false when no
|
||||||
|
// credential matched.
|
||||||
|
func Authenticate(r *http.Request, staticToken, staticPrincipal string, validator Validator) (principal string, viaStatic, ok bool) {
|
||||||
|
raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !found || raw == "" {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
if staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(staticToken)) == 1 {
|
||||||
|
return staticPrincipal, true, true
|
||||||
|
}
|
||||||
|
if validator != nil {
|
||||||
|
if sub, err := validator.Validate(r.Context(), raw); err == nil && sub != "" {
|
||||||
|
return sub, false, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeRequest parses a capture request body into a CaptureInput. Shared
|
||||||
|
// by the REST adapter and the MCP capture tool so the wire shape has one
|
||||||
|
// definition. Principal and Origin are NOT set here — the caller sets them
|
||||||
|
// from the authenticated identity.
|
||||||
|
func DecodeRequest(data []byte) (capture.CaptureInput, error) {
|
||||||
|
var b request
|
||||||
|
if err := json.Unmarshal(data, &b); err != nil {
|
||||||
|
return capture.CaptureInput{}, err
|
||||||
|
}
|
||||||
|
return b.toInput(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b request) toInput() capture.CaptureInput {
|
||||||
|
in := capture.CaptureInput{
|
||||||
|
Context: capture.CaptureContext{
|
||||||
|
Harness: b.Context.Harness,
|
||||||
|
SessionRef: b.Context.SessionRef,
|
||||||
|
Fidelity: b.Context.Fidelity,
|
||||||
|
Actor: b.Context.Actor,
|
||||||
|
Classification: b.Context.Classification,
|
||||||
|
},
|
||||||
|
DryRun: b.DryRun,
|
||||||
|
}
|
||||||
|
for _, i := range b.Insights {
|
||||||
|
in.Insights = append(in.Insights, capture.Insight{
|
||||||
|
Text: i.Text, Wing: i.Wing, Hall: i.Hall, SupersedeSlug: i.SupersedeSlug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, t := range b.Tickets {
|
||||||
|
in.Tickets = append(in.Tickets, capture.Ticket{
|
||||||
|
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if b.Summary != nil {
|
||||||
|
in.Summary = &capture.Summary{
|
||||||
|
Title: b.Summary.Title, Body: b.Summary.Body, ReposTouched: b.Summary.ReposTouched,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusFor maps a receipt to an HTTP status: 200 all-ok (or dry-run),
|
||||||
|
// 207 partial, 502 everything-failed.
|
||||||
|
func statusFor(rec capture.CaptureReceipt) int {
|
||||||
|
if rec.DryRun {
|
||||||
|
return http.StatusOK
|
||||||
|
}
|
||||||
|
var ok, fail int
|
||||||
|
for _, i := range rec.Insights {
|
||||||
|
count(&ok, &fail, i.OK)
|
||||||
|
}
|
||||||
|
for _, t := range rec.Tickets {
|
||||||
|
count(&ok, &fail, t.OK)
|
||||||
|
}
|
||||||
|
if rec.Summary != nil {
|
||||||
|
count(&ok, &fail, rec.Summary.OK)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case fail == 0:
|
||||||
|
return http.StatusOK
|
||||||
|
case ok == 0:
|
||||||
|
return http.StatusBadGateway // every persistence attempt failed
|
||||||
|
default:
|
||||||
|
return http.StatusMultiStatus // 207: partial success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func count(ok, fail *int, isOK bool) {
|
||||||
|
if isOK {
|
||||||
|
*ok++
|
||||||
|
} else {
|
||||||
|
*fail++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package capturehttp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const staticTok = "static-secret"
|
||||||
|
|
||||||
|
// fakeValidator stands in for the chassis JWT validator.
|
||||||
|
type fakeValidator struct {
|
||||||
|
subject string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeValidator) Validate(context.Context, string) (string, error) {
|
||||||
|
return f.subject, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTracker struct{ failCreate bool }
|
||||||
|
|
||||||
|
func (f fakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||||
|
if f.failCreate {
|
||||||
|
return capture.IssueRef{}, errors.New("gitea down")
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||||
|
}
|
||||||
|
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker, sovereign []string) *capturehttp.Handler {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := classification.Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := capture.NewService(brainstore.New(t.TempDir()), tr, nil, cfg, audit.NewSlogSink(nil))
|
||||||
|
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, h *capturehttp.Handler, authz string, body any) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/capture", bytes.NewReader(b))
|
||||||
|
if authz != "" {
|
||||||
|
req.Header.Set("Authorization", authz)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
return rr
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalReq() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthorizedWithoutToken(t *testing.T) {
|
||||||
|
h := newHandler(t, fakeValidator{err: errors.New("no")}, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "", internalReq())
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthorizedBadToken(t *testing.T) {
|
||||||
|
h := newHandler(t, fakeValidator{err: errors.New("bad jwt")}, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer wrong", internalReq())
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHappyPathStaticToken(t *testing.T) {
|
||||||
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "t"}},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var rec capture.CaptureReceipt
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
assert.Empty(t, rec.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfidentialViaUSNexusRefused(t *testing.T) {
|
||||||
|
// JWT principal not in the sovereign allowlist ⇒ us-nexus; confidential ⇒ 403.
|
||||||
|
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claudeai-chat", "actor": "mathias", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||||
|
assert.Contains(t, rr.Body.String(), "sovereignty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfidentialViaSovereignJWTAllowed(t *testing.T) {
|
||||||
|
// Same confidential payload, but the principal is allowlisted sovereign ⇒ allowed.
|
||||||
|
h := newHandler(t, fakeValidator{subject: "koala-cli"}, fakeTracker{}, []string{"koala-cli"})
|
||||||
|
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticTokenIsSovereignSoConfidentialAllowed(t *testing.T) {
|
||||||
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationRejectedBeforeWrite(t *testing.T) {
|
||||||
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||||
|
"context": map[string]any{"actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "x", "wing": "hyperguild", "hall": "not-a-hall"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialFailureIs207(t *testing.T) {
|
||||||
|
h := newHandler(t, nil, fakeTracker{failCreate: true}, nil)
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "ok insight", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "fails"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusMultiStatus, rr.Code)
|
||||||
|
var rec capture.CaptureReceipt
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
assert.False(t, rec.Tickets[0].OK)
|
||||||
|
assert.Len(t, rec.Errors, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDryRunWritesNothing(t *testing.T) {
|
||||||
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "a", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
"dry_run": true,
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var rec capture.CaptureReceipt
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||||
|
assert.True(t, rec.DryRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallerCannotForgeOrigin(t *testing.T) {
|
||||||
|
// Even if the body tried to assert a sovereign harness, a us-nexus JWT
|
||||||
|
// principal + confidential ⇒ refused. (Origin is server-derived.)
|
||||||
|
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer jwt", map[string]any{
|
||||||
|
"context": map[string]any{"harness": "sovereign-soil", "actor": "mathias", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refusingAudit refuses at Reserve (e.g. confidential + loki down, or floor).
|
||||||
|
type refusingAudit struct{}
|
||||||
|
|
||||||
|
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
||||||
|
return 0, errors.New("central audit sink unreachable")
|
||||||
|
}
|
||||||
|
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditUnavailableIs503(t *testing.T) {
|
||||||
|
cfg, err := classification.Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, nil, cfg, refusingAudit{})
|
||||||
|
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
||||||
|
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, internalReq())
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, rr.Code)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package capturehttp
|
||||||
|
|
||||||
|
import "github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
|
||||||
|
// OriginResolver maps an authenticated principal to its trust zone
|
||||||
|
// (spec §4.2). The mapping is server-side and never reads caller input.
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - The static-token path is a homelab CLI caller on sovereign soil →
|
||||||
|
// ZoneSovereign.
|
||||||
|
// - A JWT principal in the sovereign allowlist → ZoneSovereign.
|
||||||
|
// - Any other JWT principal (e.g. claude.ai's OAuth identity, or any
|
||||||
|
// unrecognised subject) → ZoneUSNexus.
|
||||||
|
//
|
||||||
|
// The default is the strict one: an unknown principal is treated as
|
||||||
|
// us-nexus so the I1 gate fails safe (refuses confidential), exactly as
|
||||||
|
// an untagged classification target fails safe to confidential (#50).
|
||||||
|
type OriginResolver struct {
|
||||||
|
sovereign map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOriginResolver builds a resolver whose JWT sovereign principals are
|
||||||
|
// the given subjects. The static-token caller is always sovereign and
|
||||||
|
// need not be listed.
|
||||||
|
func NewOriginResolver(sovereignPrincipals []string) OriginResolver {
|
||||||
|
m := make(map[string]bool, len(sovereignPrincipals))
|
||||||
|
for _, p := range sovereignPrincipals {
|
||||||
|
if p != "" {
|
||||||
|
m[p] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OriginResolver{sovereign: m}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the trust zone for a principal. viaStatic is true when
|
||||||
|
// the static-token auth path was taken.
|
||||||
|
func (r OriginResolver) Resolve(principal string, viaStatic bool) capture.Zone {
|
||||||
|
if viaStatic || r.sovereign[principal] {
|
||||||
|
return capture.ZoneSovereign
|
||||||
|
}
|
||||||
|
return capture.ZoneUSNexus
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Package classification defines the data-sensitivity taxonomy and the
|
||||||
|
// per-wing / per-repo tagging the capture server reads to enforce the I1
|
||||||
|
// sovereignty gate (issue #50, capture spec §4.1).
|
||||||
|
//
|
||||||
|
// The single load-bearing property is fail-safe-to-strictest: a target
|
||||||
|
// with no explicit tag and no known default classifies as Confidential,
|
||||||
|
// never as something more permissive. A missing tag must never silently
|
||||||
|
// downgrade — that would turn the I1 gate into theatre.
|
||||||
|
//
|
||||||
|
// Classification is read from an optional classification.yaml at the
|
||||||
|
// brain root. A central, Flux-reconcilable file is deliberate: it is
|
||||||
|
// auditable in one place (I2/I5), it does not require a live Gitea client
|
||||||
|
// to classify a repo (so this package has no dependency on the gitea
|
||||||
|
// tracker work), and it avoids tagging a wing's _index.md frontmatter —
|
||||||
|
// which BuildWingIndex regenerates and would clobber.
|
||||||
|
package classification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level is a data-sensitivity tier. Higher is stricter, so the "stricter
|
||||||
|
// wins" rule (spec §4.1 model C) is a plain max.
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Public Level = iota
|
||||||
|
Internal
|
||||||
|
Confidential
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the canonical lowercase token for a level.
|
||||||
|
func (l Level) String() string {
|
||||||
|
switch l {
|
||||||
|
case Public:
|
||||||
|
return "public"
|
||||||
|
case Internal:
|
||||||
|
return "internal"
|
||||||
|
case Confidential:
|
||||||
|
return "confidential"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("level(%d)", int(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLevel parses a level token (case-insensitive, surrounding space
|
||||||
|
// tolerated). An unknown token is an error — callers must decide what to
|
||||||
|
// do with bad input rather than have it silently coerced.
|
||||||
|
func ParseLevel(s string) (Level, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "public":
|
||||||
|
return Public, nil
|
||||||
|
case "internal":
|
||||||
|
return Internal, nil
|
||||||
|
case "confidential":
|
||||||
|
return Confidential, nil
|
||||||
|
default:
|
||||||
|
return Confidential, fmt.Errorf("unknown classification level %q (want public/internal/confidential)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stricter returns the more restrictive of two levels.
|
||||||
|
func Stricter(a, b Level) Level {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetKind distinguishes the two kinds of capture destination.
|
||||||
|
type TargetKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
WingTarget TargetKind = iota // a brain wing (insights land here)
|
||||||
|
RepoTarget // a Gitea repo (tickets / summaries land here)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target names a capture destination to classify.
|
||||||
|
type Target struct {
|
||||||
|
Kind TargetKind
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the explicit per-wing / per-repo classification tags read
|
||||||
|
// from classification.yaml. Absent entries fall through to the built-in
|
||||||
|
// defaults in defaultFor. The zero value (no file) is valid and applies
|
||||||
|
// defaults to everything.
|
||||||
|
type Config struct {
|
||||||
|
wings map[string]Level
|
||||||
|
repos map[string]Level
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawConfig is the on-disk YAML shape: string→string maps, parsed into
|
||||||
|
// validated levels by Load.
|
||||||
|
type rawConfig struct {
|
||||||
|
Wings map[string]string `yaml:"wings"`
|
||||||
|
Repos map[string]string `yaml:"repos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads classification.yaml from brainDir. An absent file is not an
|
||||||
|
// error — it yields an empty config where every target classifies by the
|
||||||
|
// built-in defaults. A malformed file, or any unparseable level token in
|
||||||
|
// it, is a hard error: a classification source the server cannot trust
|
||||||
|
// must fail loud, not degrade silently.
|
||||||
|
func Load(brainDir string) (*Config, error) {
|
||||||
|
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read classification.yaml: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw rawConfig
|
||||||
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse classification.yaml: %w", err)
|
||||||
|
}
|
||||||
|
for name, lvl := range raw.Wings {
|
||||||
|
parsed, perr := ParseLevel(lvl)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("wing %q: %w", name, perr)
|
||||||
|
}
|
||||||
|
cfg.wings[normalise(name)] = parsed
|
||||||
|
}
|
||||||
|
for name, lvl := range raw.Repos {
|
||||||
|
parsed, perr := ParseLevel(lvl)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("repo %q: %w", name, perr)
|
||||||
|
}
|
||||||
|
cfg.repos[normalise(name)] = parsed
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive returns the classification for any target — the function the
|
||||||
|
// capture use-case calls per item.
|
||||||
|
func (c *Config) Derive(t Target) Level {
|
||||||
|
if t.Kind == RepoTarget {
|
||||||
|
return c.Repo(t.Name)
|
||||||
|
}
|
||||||
|
return c.Wing(t.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wing classifies a brain wing: an explicit tag wins, else defaults.
|
||||||
|
func (c *Config) Wing(name string) Level {
|
||||||
|
if lvl, ok := c.wings[normalise(name)]; ok {
|
||||||
|
return lvl
|
||||||
|
}
|
||||||
|
return defaultFor(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo classifies a Gitea repo: an explicit tag wins, else defaults.
|
||||||
|
func (c *Config) Repo(name string) Level {
|
||||||
|
if lvl, ok := c.repos[normalise(name)]; ok {
|
||||||
|
return lvl
|
||||||
|
}
|
||||||
|
return defaultFor(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultFor applies the built-in defaulting rules when a target has no
|
||||||
|
// explicit tag:
|
||||||
|
// - client-* → Confidential (client work is confidential by default)
|
||||||
|
// - hyperguild / homelab → Internal (the operator's own infra)
|
||||||
|
// - everything else → Confidential (fail safe to strictest)
|
||||||
|
func defaultFor(name string) Level {
|
||||||
|
n := normalise(name)
|
||||||
|
if strings.HasPrefix(n, "client-") {
|
||||||
|
return Confidential
|
||||||
|
}
|
||||||
|
switch n {
|
||||||
|
case "hyperguild", "homelab":
|
||||||
|
return Internal
|
||||||
|
default:
|
||||||
|
return Confidential
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalise lowercases and trims a wing/repo name so matching and the
|
||||||
|
// client-* prefix check are case-insensitive.
|
||||||
|
func normalise(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(name))
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package classification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLevelOrderingAndString(t *testing.T) {
|
||||||
|
assert.True(t, Public < Internal)
|
||||||
|
assert.True(t, Internal < Confidential)
|
||||||
|
assert.Equal(t, "public", Public.String())
|
||||||
|
assert.Equal(t, "internal", Internal.String())
|
||||||
|
assert.Equal(t, "confidential", Confidential.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLevel(t *testing.T) {
|
||||||
|
for s, want := range map[string]Level{
|
||||||
|
"public": Public, "internal": Internal, "confidential": Confidential,
|
||||||
|
"PUBLIC": Public, " Confidential ": Confidential,
|
||||||
|
} {
|
||||||
|
got, err := ParseLevel(s)
|
||||||
|
require.NoError(t, err, s)
|
||||||
|
assert.Equal(t, want, got, s)
|
||||||
|
}
|
||||||
|
_, err := ParseLevel("secret")
|
||||||
|
require.Error(t, err, "unknown level must error, not silently default")
|
||||||
|
_, err = ParseLevel("")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStricterReturnsMax(t *testing.T) {
|
||||||
|
assert.Equal(t, Confidential, Stricter(Internal, Confidential))
|
||||||
|
assert.Equal(t, Confidential, Stricter(Confidential, Public))
|
||||||
|
assert.Equal(t, Internal, Stricter(Public, Internal))
|
||||||
|
assert.Equal(t, Public, Stricter(Public, Public))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAbsentFileIsDefaultsOnly(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err, "absent classification.yaml must not be an error — defaults apply")
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
// Pure defaulting still works.
|
||||||
|
assert.Equal(t, Internal, cfg.Wing("hyperguild"))
|
||||||
|
assert.Equal(t, Confidential, cfg.Wing("anything-unknown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadParsesExplicitTags(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "classification.yaml"), []byte(
|
||||||
|
"wings:\n research-public: public\n hyperguild: confidential\nrepos:\n infra: internal\n research-public: public\n",
|
||||||
|
), 0o644))
|
||||||
|
|
||||||
|
cfg, err := Load(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Explicit tag wins over the built-in default (hyperguild default is internal).
|
||||||
|
assert.Equal(t, Confidential, cfg.Wing("hyperguild"))
|
||||||
|
// Explicit public is honoured.
|
||||||
|
assert.Equal(t, Public, cfg.Wing("research-public"))
|
||||||
|
assert.Equal(t, Internal, cfg.Repo("infra"))
|
||||||
|
assert.Equal(t, Public, cfg.Repo("research-public"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsUnknownLevelInFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "classification.yaml"),
|
||||||
|
[]byte("wings:\n x: top-secret\n"), 0o644))
|
||||||
|
_, err := Load(dir)
|
||||||
|
require.Error(t, err, "an unparseable level in the config must fail loud, not be ignored")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWingDefaulting(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
cases := map[string]Level{
|
||||||
|
"client-seb": Confidential, // client-* → confidential
|
||||||
|
"client-mastercard": Confidential,
|
||||||
|
"hyperguild": Internal,
|
||||||
|
"homelab": Internal,
|
||||||
|
"jepa-fx": Confidential, // unknown → fail safe to strictest
|
||||||
|
"": Confidential, // empty → fail safe
|
||||||
|
}
|
||||||
|
for wing, want := range cases {
|
||||||
|
assert.Equal(t, want, cfg.Wing(wing), "wing %q", wing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDefaulting(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Confidential, cfg.Repo("client-seb-pipeline"))
|
||||||
|
assert.Equal(t, Internal, cfg.Repo("hyperguild"))
|
||||||
|
assert.Equal(t, Confidential, cfg.Repo("some-unknown-repo"), "untagged repo → confidential (fail safe)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveUnifiedTarget(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Internal, cfg.Derive(Target{Kind: WingTarget, Name: "homelab"}))
|
||||||
|
assert.Equal(t, Confidential, cfg.Derive(Target{Kind: RepoTarget, Name: "client-x"}))
|
||||||
|
assert.Equal(t, Confidential, cfg.Derive(Target{Kind: WingTarget, Name: "untagged"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaseInsensitiveMatching(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Confidential, cfg.Wing("Client-SEB"), "client- prefix match is case-insensitive")
|
||||||
|
assert.Equal(t, Internal, cfg.Wing("HyperGuild"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// ingestion/internal/extract/docmark.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// docmarkRequest is a JSON-RPC 2.0 tools/call request for docmark's
|
||||||
|
// convert_to_markdown tool.
|
||||||
|
type docmarkRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments struct {
|
||||||
|
ContentBase64 string `json:"content_base64"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
} `json:"arguments"`
|
||||||
|
} `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type docmarkResponse struct {
|
||||||
|
Result *struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
} `json:"result"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractViaDocmark converts path (PDF/DOCX/XLSX/PPTX/image) to Markdown by
|
||||||
|
// calling the docmark MCP server with a single self-contained tools/call
|
||||||
|
// request (docmark runs stateless_http -- no initialize handshake or session
|
||||||
|
// ID needed). DOCMARK_URL must be set (e.g.
|
||||||
|
// http://docmark.docmark.svc.cluster.local:3001/mcp); DOCMARK_BEARER_TOKEN
|
||||||
|
// is docmark's static bearer (network is docmark's primary auth boundary,
|
||||||
|
// this is defense-in-depth — ADR-0013).
|
||||||
|
func extractViaDocmark(path string) (string, error) {
|
||||||
|
url := os.Getenv("DOCMARK_URL")
|
||||||
|
if url == "" {
|
||||||
|
return "", fmt.Errorf("extractViaDocmark: DOCMARK_URL is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody docmarkRequest
|
||||||
|
reqBody.JSONRPC = "2.0"
|
||||||
|
reqBody.ID = 1
|
||||||
|
reqBody.Method = "tools/call"
|
||||||
|
reqBody.Params.Name = "convert_to_markdown"
|
||||||
|
reqBody.Params.Arguments.ContentBase64 = base64.StdEncoding.EncodeToString(raw)
|
||||||
|
reqBody.Params.Arguments.Filename = fileBase(path)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal docmark request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("build docmark request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
if tok := os.Getenv("DOCMARK_BEARER_TOKEN"); tok != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("call docmark: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read docmark response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("docmark: HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody := body
|
||||||
|
if data := sseDataPayload(body); data != nil {
|
||||||
|
jsonBody = data
|
||||||
|
}
|
||||||
|
|
||||||
|
var out docmarkResponse
|
||||||
|
if err := json.Unmarshal(jsonBody, &out); err != nil {
|
||||||
|
return "", fmt.Errorf("decode docmark response: %w", err)
|
||||||
|
}
|
||||||
|
if out.Error != nil {
|
||||||
|
return "", fmt.Errorf("docmark: %s", out.Error.Message)
|
||||||
|
}
|
||||||
|
if out.Result == nil || len(out.Result.Content) == 0 {
|
||||||
|
return "", fmt.Errorf("docmark: empty response")
|
||||||
|
}
|
||||||
|
text := out.Result.Content[0].Text
|
||||||
|
if out.Result.IsError {
|
||||||
|
return "", fmt.Errorf("docmark: %s", text)
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sseDataPayload extracts the JSON payload from an SSE-framed response body
|
||||||
|
// ("event: message\r\ndata: {...}\r\n\r\n"). docmark's Streamable-HTTP
|
||||||
|
// transport frames every response this way (Content-Type: text/event-stream)
|
||||||
|
// regardless of stateless_http — that flag removes the session/initialize
|
||||||
|
// requirement, not the SSE wire framing. Returns nil if body isn't SSE-framed
|
||||||
|
// (e.g. a plain-JSON response, kept as a fallback for forward-compatibility).
|
||||||
|
func sseDataPayload(body []byte) []byte {
|
||||||
|
const prefix = "data: "
|
||||||
|
for _, line := range bytes.Split(body, []byte("\n")) {
|
||||||
|
line = bytes.TrimRight(line, "\r")
|
||||||
|
if bytes.HasPrefix(line, []byte(prefix)) {
|
||||||
|
return bytes.TrimPrefix(line, []byte(prefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileBase returns the final path segment (like filepath.Base, kept local to
|
||||||
|
// avoid importing path/filepath just for this one call).
|
||||||
|
func fileBase(path string) string {
|
||||||
|
for i := len(path) - 1; i >= 0; i-- {
|
||||||
|
if path[i] == '/' || path[i] == '\\' {
|
||||||
|
return path[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// ingestion/internal/extract/docmark_test.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mcpToolResult mirrors the shape of docmark's JSON-RPC tools/call response.
|
||||||
|
type mcpToolResult struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result *struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
} `json:"result,omitempty"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMCPResponse mirrors docmark's REAL response framing (empirically
|
||||||
|
// confirmed against the live server): Content-Type: text/event-stream,
|
||||||
|
// body is SSE-framed ("event: message\r\ndata: {...}\r\n\r\n"), not bare
|
||||||
|
// JSON -- inherent to MCP Streamable-HTTP, independent of stateless_http.
|
||||||
|
func writeMCPResponse(w http.ResponseWriter, body mcpToolResult) {
|
||||||
|
payload, _ := json.Marshal(body)
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("event: message\r\ndata: "))
|
||||||
|
_, _ = w.Write(payload)
|
||||||
|
_, _ = w.Write([]byte("\r\n\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_Success(t *testing.T) {
|
||||||
|
var gotAuth, gotAccept string
|
||||||
|
var gotBody map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
gotAccept = r.Header.Get("Accept")
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &gotBody)
|
||||||
|
writeMCPResponse(w, mcpToolResult{
|
||||||
|
JSONRPC: "2.0", ID: 1,
|
||||||
|
Result: &struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}{
|
||||||
|
Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{{Type: "text", Text: "# Converted\n\nhello"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "test-token-123")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("fake docx bytes"), 0o644))
|
||||||
|
|
||||||
|
got, err := extractViaDocmark(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "# Converted\n\nhello", got)
|
||||||
|
assert.Equal(t, "Bearer test-token-123", gotAuth)
|
||||||
|
assert.Contains(t, gotAccept, "application/json")
|
||||||
|
params, _ := gotBody["params"].(map[string]any)
|
||||||
|
require.NotNil(t, params)
|
||||||
|
assert.Equal(t, "convert_to_markdown", params["name"])
|
||||||
|
args, _ := params["arguments"].(map[string]any)
|
||||||
|
require.NotNil(t, args)
|
||||||
|
assert.Equal(t, "doc.docx", args["filename"])
|
||||||
|
assert.NotEmpty(t, args["content_base64"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_NotConfigured(t *testing.T) {
|
||||||
|
t.Setenv("DOCMARK_URL", "")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
_, err := extractViaDocmark(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "DOCMARK_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_ToolErrorSurfacesMessage(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeMCPResponse(w, mcpToolResult{
|
||||||
|
JSONRPC: "2.0", ID: 1,
|
||||||
|
Result: &struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}{
|
||||||
|
Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{{Type: "text", Text: "unsupported format for 'doc.docx'"}},
|
||||||
|
IsError: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
_, err := extractViaDocmark(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unsupported format")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_HTTPErrorSurfaces(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte("unauthorized"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "wrong")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
_, err := extractViaDocmark(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "401")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestText_RoutesDocxXlsxPptxImagesToDocmark(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeMCPResponse(w, mcpToolResult{
|
||||||
|
JSONRPC: "2.0", ID: 1,
|
||||||
|
Result: &struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}{
|
||||||
|
Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{{Type: "text", Text: "converted"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
|
for _, ext := range []string{".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg"} {
|
||||||
|
t.Run(ext, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "f"+ext)
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
got, err := Text(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "converted", got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Text reads the file at path and returns its plain-text content.
|
// Text reads the file at path and returns its plain-text content.
|
||||||
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext).
|
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext),
|
||||||
|
// .docx/.xlsx/.pptx/.png/.jpg/.jpeg (via docmark, ADR-0013 -- requires
|
||||||
|
// DOCMARK_URL to be set; see docmark.go).
|
||||||
func Text(path string) (string, error) {
|
func Text(path string) (string, error) {
|
||||||
ext := strings.ToLower(fileExt(path))
|
ext := strings.ToLower(fileExt(path))
|
||||||
switch ext {
|
switch ext {
|
||||||
@@ -20,6 +22,8 @@ func Text(path string) (string, error) {
|
|||||||
return string(b), nil
|
return string(b), nil
|
||||||
case ".pdf":
|
case ".pdf":
|
||||||
return extractPDF(path)
|
return extractPDF(path)
|
||||||
|
case ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||||
|
return extractViaDocmark(path)
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Package gitea implements capture.IssueTracker against a Gitea instance
|
||||||
|
// over its REST API. It is the new outbound dependency the brain server
|
||||||
|
// gains for the capture capability (#49c/#52): the server otherwise does
|
||||||
|
// brain-local file ops only.
|
||||||
|
//
|
||||||
|
// Owner is hard-coded to the operator and never taken from caller input.
|
||||||
|
// The API token is read once at construction, held in the struct, and
|
||||||
|
// never logged or placed in argv — it travels only in the Authorization
|
||||||
|
// header of outbound requests (AGENTS.md secret-handling).
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// owner is the fixed repository owner for every ticket operation. It is a
|
||||||
|
// constant, not a parameter, so a caller can never redirect a write to
|
||||||
|
// another owner's repo.
|
||||||
|
const owner = "mathias"
|
||||||
|
|
||||||
|
// Client is a Gitea REST API IssueTracker.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Client. It returns nil when either baseURL or token is
|
||||||
|
// empty, so callers can treat missing config as "tracker disabled" with a
|
||||||
|
// single nil check (mirrors embed.New).
|
||||||
|
func New(baseURL, token string) *Client {
|
||||||
|
if baseURL == "" || token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
token: token,
|
||||||
|
http: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueResponse is the subset of a Gitea issue/comment payload we read.
|
||||||
|
type issueResponse struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssue opens a new issue under the fixed owner.
|
||||||
|
func (c *Client) CreateIssue(ctx context.Context, repo, title, body string) (capture.IssueRef, error) {
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo),
|
||||||
|
map[string]any{"title": title, "body": body}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: out.Number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentIssue posts a comment on an existing issue.
|
||||||
|
func (c *Client) CommentIssue(ctx context.Context, repo string, number int, body string) (capture.IssueRef, error) {
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number),
|
||||||
|
map[string]any{"body": body}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseIssue closes an issue, first posting a closing comment when one is
|
||||||
|
// given (empty comment ⇒ close only).
|
||||||
|
func (c *Client) CloseIssue(ctx context.Context, repo string, number int, comment string) (capture.IssueRef, error) {
|
||||||
|
if strings.TrimSpace(comment) != "" {
|
||||||
|
if _, err := c.CommentIssue(ctx, repo, number, comment); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPatch,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number),
|
||||||
|
map[string]any{"state": "closed"}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile creates or updates a file in repo at path via the Gitea
|
||||||
|
// contents API — the SummaryWriter port (#66). It upserts: a GET resolves
|
||||||
|
// the current blob sha (if any) so an existing file is updated rather than
|
||||||
|
// rejected (the richer-fidelity-supersedes rule for re-captured sessions).
|
||||||
|
// Owner is the fixed const, like every other call.
|
||||||
|
func (c *Client) WriteFile(ctx context.Context, repo, path, content string) error {
|
||||||
|
cpath := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
|
||||||
|
sha, err := c.fileSHA(ctx, cpath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"message": "capture: " + path,
|
||||||
|
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||||
|
}
|
||||||
|
// Gitea contents API: POST creates a new file, PUT updates an existing
|
||||||
|
// one (PUT requires the current sha). Pick by whether the file exists.
|
||||||
|
method := http.MethodPost
|
||||||
|
if sha != "" {
|
||||||
|
method = http.MethodPut
|
||||||
|
payload["sha"] = sha
|
||||||
|
}
|
||||||
|
status, body, err := c.request(ctx, method, cpath, payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return fmt.Errorf("gitea %s %s: status %d: %s", method, cpath, status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileSHA returns the current blob sha for a contents path, or "" when the
|
||||||
|
// file does not exist (404). Any other non-2xx is an error.
|
||||||
|
func (c *Client) fileSHA(ctx context.Context, cpath string) (string, error) {
|
||||||
|
status, body, err := c.request(ctx, http.MethodGet, cpath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return "", fmt.Errorf("gitea GET %s: status %d: %s", cpath, status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var meta struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &meta); err != nil {
|
||||||
|
return "", fmt.Errorf("gitea GET %s: decode: %w", cpath, err)
|
||||||
|
}
|
||||||
|
return meta.SHA, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs a JSON request against the Gitea API and decodes a 2xx
|
||||||
|
// response into out. Errors carry the status and a truncated body for
|
||||||
|
// diagnosis but never the token.
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, payload any, out *issueResponse) error {
|
||||||
|
status, body, err := c.request(ctx, method, path, payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return fmt.Errorf("gitea %s %s: status %d: %s", method, path, status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
if out != nil && len(body) > 0 {
|
||||||
|
if err := json.Unmarshal(body, out); err != nil {
|
||||||
|
return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// request is the shared HTTP path: marshals an optional JSON payload,
|
||||||
|
// attaches auth (token only ever in the header), and returns the status +
|
||||||
|
// body so callers can branch on status (e.g. 404) without it being an
|
||||||
|
// error. Never logs the token.
|
||||||
|
func (c *Client) request(ctx context.Context, method, path string, payload any) (int, []byte, error) {
|
||||||
|
var reader io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
reqBody, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(reqBody)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("gitea %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||||
|
return resp.StatusCode, body, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testToken = "super-secret-token-value"
|
||||||
|
|
||||||
|
func TestNewNilWhenUnconfigured(t *testing.T) {
|
||||||
|
assert.Nil(t, gitea.New("", testToken))
|
||||||
|
assert.Nil(t, gitea.New("https://git.example", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIssueForcesOwnerAndAuth(t *testing.T) {
|
||||||
|
var gotPath, gotAuth, gotBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
gotBody = string(b)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 42, "html_url": "https://git.d-ma.be/mathias/hyperguild/issues/42"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.New(srv.URL, testToken)
|
||||||
|
require.NotNil(t, c)
|
||||||
|
ref, err := c.CreateIssue(context.Background(), "hyperguild", "Do the thing", "details")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues", gotPath, "owner forced to mathias")
|
||||||
|
assert.Equal(t, "token "+testToken, gotAuth)
|
||||||
|
assert.Contains(t, gotBody, "Do the thing")
|
||||||
|
assert.Equal(t, "hyperguild", ref.Repo)
|
||||||
|
assert.Equal(t, 42, ref.Number)
|
||||||
|
assert.Contains(t, ref.URL, "/issues/42")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentIssue(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"html_url": "https://git/c/1"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ref, err := gitea.New(srv.URL, testToken).CommentIssue(context.Background(), "hyperguild", 7, "a comment")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues/7/comments", gotPath)
|
||||||
|
assert.Equal(t, 7, ref.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIssueWithComment(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
var states []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.Method+" "+r.URL.Path)
|
||||||
|
if r.Method == http.MethodPatch {
|
||||||
|
var body map[string]any
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &body)
|
||||||
|
states = append(states, body["state"].(string))
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 9, "html_url": "https://git/i/9"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ref, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 9, "closing because done")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 9, ref.Number)
|
||||||
|
// Comment posted first, then state PATCHed to closed.
|
||||||
|
assert.Contains(t, paths, "POST /api/v1/repos/mathias/hyperguild/issues/9/comments")
|
||||||
|
assert.Contains(t, paths, "PATCH /api/v1/repos/mathias/hyperguild/issues/9")
|
||||||
|
assert.Equal(t, []string{"closed"}, states)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIssueNoComment(t *testing.T) {
|
||||||
|
var commented bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/comments") {
|
||||||
|
commented = true
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 3, "html_url": "https://git/i/3"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 3, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, commented, "empty comment ⇒ no comment POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPathDoesNotLeakToken(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("boom"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := gitea.New(srv.URL, testToken).CreateIssue(context.Background(), "hyperguild", "t", "b")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), testToken, "token must never appear in an error message")
|
||||||
|
assert.Contains(t, err.Error(), "500")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileCreatesNewFile(t *testing.T) {
|
||||||
|
var getPath, postPath, postBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
getPath = r.URL.Path
|
||||||
|
w.WriteHeader(http.StatusNotFound) // file does not exist yet
|
||||||
|
case http.MethodPost: // gitea contents API: POST = create
|
||||||
|
postPath = r.URL.Path
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
postBody = string(b)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}})
|
||||||
|
default:
|
||||||
|
t.Errorf("create must POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(),
|
||||||
|
"ai-sessions", "summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", "# Summary\n\nbody\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/ai-sessions/contents/summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", getPath)
|
||||||
|
assert.Equal(t, getPath, postPath)
|
||||||
|
// base64 of the content, no sha on create.
|
||||||
|
assert.Contains(t, postBody, "IyBTdW1tYXJ5") // base64("# Summary")
|
||||||
|
assert.NotContains(t, postBody, `"sha"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileUpdatesExisting(t *testing.T) {
|
||||||
|
var putBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"sha": "deadbeef"})
|
||||||
|
case http.MethodPut:
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
putBody = string(b)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "new")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, putBody, `"sha":"deadbeef"`, "existing file → update with sha")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileErrorNoTokenLeak(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = w.Write([]byte("bad"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "x")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), testToken)
|
||||||
|
assert.Contains(t, err.Error(), "422")
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
@@ -38,7 +39,7 @@ func (s *Server) tools() []map[string]any {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
return []map[string]any{
|
tools := []map[string]any{
|
||||||
{
|
{
|
||||||
"name": "brain_query",
|
"name": "brain_query",
|
||||||
"description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files. Optionally scope by wing (topic domain) and hall (memory type).",
|
"description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files. Optionally scope by wing (topic domain) and hall (memory type).",
|
||||||
@@ -81,6 +82,21 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"path": str("brain-relative path to the note; equivalent to id"),
|
"path": str("brain-relative path to the note; equivalent to id"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_pending",
|
||||||
|
"description": "List notes in brain/raw/ awaiting human promotion to the wiki, oldest-first. Returns filename, created_at, size_bytes, excerpt. The human-review queue complement to brain_promote.",
|
||||||
|
"inputSchema": schema([]string{}, map[string]any{}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_promote",
|
||||||
|
"description": "Promote a brain/raw/ note into brain/wiki/<wing>/<hall>/: rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at + custom fields), deletes the source, rebuilds the wing index, runs auto-tunnel. Errors (without touching the fs) on invalid hall or a slug collision. Returns {path}.",
|
||||||
|
"inputSchema": schema([]string{"filename", "wing", "hall"}, map[string]any{
|
||||||
|
"filename": str("basename in brain/raw/, e.g. 2026-06-01-lejpa-decision.md"),
|
||||||
|
"wing": str("target wing, e.g. jepa-fx"),
|
||||||
|
"hall": enum("target hall", halls...),
|
||||||
|
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "brain_tunnel",
|
"name": "brain_tunnel",
|
||||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
||||||
@@ -171,6 +187,13 @@ func (s *Server) tools() []map[string]any {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
// The capture relay tool (#55) is advertised only when wired via
|
||||||
|
// WithCapture — MCP-native harnesses (claude.ai, Crush, Pi, LLM Council)
|
||||||
|
// reach capture through it.
|
||||||
|
if s.capture != nil {
|
||||||
|
tools = append(tools, captureToolDescriptor())
|
||||||
|
}
|
||||||
|
return tools
|
||||||
}
|
}
|
||||||
|
|
||||||
type brainQueryArgs struct {
|
type brainQueryArgs struct {
|
||||||
@@ -219,7 +242,11 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
// Delegate to the shared BrainStore so write+index+tunnel+graph live in
|
||||||
|
// one implementation (capture uses the same store). The read-after-write
|
||||||
|
// handle {id, path, content_hash} comes back from the store; path is kept
|
||||||
|
// for backward compatibility.
|
||||||
|
ref, err := s.store.Write(ctx, capture.Note{
|
||||||
Content: a.Content,
|
Content: a.Content,
|
||||||
Filename: a.Filename,
|
Filename: a.Filename,
|
||||||
Type: a.Type,
|
Type: a.Type,
|
||||||
@@ -230,22 +257,7 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Auto-regenerate the wing _index.md when the write landed in the
|
return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash})
|
||||||
// structured wiki, and auto-tunnel cross-wing matches. Both are
|
|
||||||
// best-effort: the note is already written.
|
|
||||||
if a.Wing != "" && a.Hall != "" {
|
|
||||||
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
|
|
||||||
slog.Warn("brain_write: auto-index failed", "wing", a.Wing, "err", err)
|
|
||||||
}
|
|
||||||
if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil {
|
|
||||||
slog.Warn("brain_write: auto-tunnel failed", "src", relPath, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.indexInGraph(ctx, "brain_write", relPath)
|
|
||||||
// Read-after-write handle: id == relPath, content_hash == sha256 of
|
|
||||||
// the bytes just written. path is kept for backward compatibility.
|
|
||||||
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
|
||||||
return json.Marshal(map[string]string{"id": relPath, "path": relPath, "content_hash": hash})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type brainUpdateArgs struct {
|
type brainUpdateArgs struct {
|
||||||
@@ -274,50 +286,26 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
|
|||||||
return nil, fmt.Errorf("content is required")
|
return nil, fmt.Errorf("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := api.UpdateNoteOptions{Content: a.Content, Reason: a.Reason}
|
// path takes precedence over slug; the store treats any slug containing
|
||||||
switch {
|
// a slash as a full brain-relative path (issue #45: "slug ... OR path").
|
||||||
case a.Path != "":
|
slug := a.Slug
|
||||||
opts.Path = a.Path
|
if a.Path != "" {
|
||||||
case strings.Contains(a.Slug, "/"):
|
slug = a.Path
|
||||||
// slug carries a full path (issue #45: "slug ... OR full path").
|
|
||||||
opts.Path = a.Slug
|
|
||||||
default:
|
|
||||||
opts.Wing, opts.Hall, opts.Slug = a.Wing, a.Hall, a.Slug
|
|
||||||
}
|
}
|
||||||
|
ref, err := s.store.Update(ctx, slug, capture.Note{
|
||||||
relPath, hash, _, err := api.UpdateNote(s.brainDir, opts)
|
Content: a.Content,
|
||||||
|
Wing: a.Wing,
|
||||||
|
Hall: a.Hall,
|
||||||
|
Reason: a.Reason,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort wiki upkeep, mirroring brain_write: rebuild the wing
|
|
||||||
// _index and re-tunnel cross-wing matches against the new body. Both
|
|
||||||
// are idempotent and never block — the note is already superseded.
|
|
||||||
if wing := wingFromRelPath(relPath); wing != "" {
|
|
||||||
if err := brain.BuildWingIndex(s.brainDir, wing); err != nil {
|
|
||||||
slog.Warn("brain_update: auto-index failed", "wing", wing, "err", err)
|
|
||||||
}
|
|
||||||
if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil {
|
|
||||||
slog.Warn("brain_update: auto-tunnel failed", "src", relPath, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.indexInGraph(ctx, "brain_update", relPath)
|
|
||||||
|
|
||||||
return json.Marshal(map[string]any{
|
return json.Marshal(map[string]any{
|
||||||
"id": relPath, "path": relPath, "content_hash": hash, "superseded": true,
|
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// wingFromRelPath extracts the wing segment from a structured wiki path
|
|
||||||
// (wiki/<wing>/<hall>/<slug>.md). Returns "" for legacy/non-wiki paths.
|
|
||||||
func wingFromRelPath(relPath string) string {
|
|
||||||
parts := strings.Split(relPath, "/")
|
|
||||||
if len(parts) >= 4 && parts[0] == "wiki" {
|
|
||||||
return parts[1]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type brainGetArgs struct {
|
type brainGetArgs struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
@@ -327,7 +315,7 @@ type brainGetArgs struct {
|
|||||||
// path — the de-facto handle). Read-only; the create-path read-after-
|
// path — the de-facto handle). Read-only; the create-path read-after-
|
||||||
// write primitive that lets callers confirm a write landed without a
|
// write primitive that lets callers confirm a write landed without a
|
||||||
// lexical re-query.
|
// lexical re-query.
|
||||||
func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
var a brainGetArgs
|
var a brainGetArgs
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
@@ -339,16 +327,50 @@ func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMess
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
return nil, fmt.Errorf("id or path is required")
|
return nil, fmt.Errorf("id or path is required")
|
||||||
}
|
}
|
||||||
fm, body, hash, err := api.ReadNote(s.brainDir, target)
|
note, err := s.store.Get(ctx, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return json.Marshal(map[string]any{
|
return json.Marshal(map[string]any{
|
||||||
"id": target, "path": target, "content_hash": hash,
|
"id": note.ID, "path": note.Path, "content_hash": note.ContentHash,
|
||||||
"frontmatter": fm, "body": body,
|
"frontmatter": note.Frontmatter, "body": note.Body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// brainPending lists the raw/ review queue (oldest-first).
|
||||||
|
func (s *Server) brainPending(_ context.Context, _ json.RawMessage) (json.RawMessage, error) {
|
||||||
|
pending, err := api.ListPending(s.brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"pending": pending})
|
||||||
|
}
|
||||||
|
|
||||||
|
type brainPromoteArgs struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Wing string `json:"wing"`
|
||||||
|
Hall string `json:"hall"`
|
||||||
|
Slug string `json:"slug,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
||||||
|
// rewrite + index + auto-tunnel, all owned by api.PromoteNote) and then
|
||||||
|
// re-indexes it into the graph. The human-facing complement to brain_write.
|
||||||
|
func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a brainPromoteArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
||||||
|
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.indexInGraph(ctx, "brain_promote", relPath)
|
||||||
|
return json.Marshal(map[string]string{"path": relPath})
|
||||||
|
}
|
||||||
|
|
||||||
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
||||||
// logs failures but never propagates them — the underlying write/ingest
|
// logs failures but never propagates them — the underlying write/ingest
|
||||||
// has already succeeded and the graph is an augmentation, not a
|
// has already succeeded and the graph is an augmentation, not a
|
||||||
|
|||||||
@@ -332,3 +332,61 @@ func TestSessionLogRequiresSessionID(t *testing.T) {
|
|||||||
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
||||||
require.NotNil(t, resp["error"])
|
require.NotNil(t, resp["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBrainPendingListsRaw(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
raw := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-06-01-x.md"),
|
||||||
|
[]byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\npending body\n"), 0o644))
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
assert.Contains(t, text, "2026-06-01-x.md")
|
||||||
|
assert.Contains(t, text, "pending body")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainPendingEmpty(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
resp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||||
|
require.Nil(t, resp["error"])
|
||||||
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
assert.Contains(t, text, `"pending":[]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainPromoteMovesToWiki(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
raw := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-06-01-decision.md"),
|
||||||
|
[]byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\n# D\n\nbody\n"), 0o644))
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_promote", map[string]any{
|
||||||
|
"filename": "2026-06-01-decision.md", "wing": "jepa-fx", "hall": "decisions",
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||||
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
assert.Contains(t, text, "wiki/jepa-fx/decisions/decision.md")
|
||||||
|
|
||||||
|
_, err := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/decisions/decision.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, srcErr := os.Stat(filepath.Join(raw, "2026-06-01-decision.md"))
|
||||||
|
assert.True(t, os.IsNotExist(srcErr), "source removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainPromoteInvalidHallErrors(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
raw := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "x.md"), []byte("body\n"), 0o644))
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
resp := toolCall(t, srv, "brain_promote", map[string]any{
|
||||||
|
"filename": "x.md", "wing": "a", "hall": "garbage",
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
|
||||||
|
assert.NoError(t, srcErr, "source untouched on validation error")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||||
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
||||||
// brain_index, brain_tunnel, brain_ingest, brain_ingest_raw,
|
// brain_pending, brain_promote, brain_index, brain_tunnel, brain_ingest,
|
||||||
// brain_answer, brain_classify, brain_graph, brain_context, session_log.
|
// brain_ingest_raw, brain_answer, brain_classify, brain_graph,
|
||||||
|
// brain_context, session_log, and capture (the #55 relay tool, registered
|
||||||
|
// only when WithCapture is set).
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,6 +12,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
@@ -46,6 +51,21 @@ type Server struct {
|
|||||||
vector search.VectorSearcher // nil = BM25-only retrieval
|
vector search.VectorSearcher // nil = BM25-only retrieval
|
||||||
embedder search.Embedder // nil = BM25-only retrieval
|
embedder search.Embedder // nil = BM25-only retrieval
|
||||||
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
||||||
|
store *brainstore.Store // shared brain write/update/get impl (also used by capture)
|
||||||
|
tracker capture.IssueTracker // nil = no Gitea ticket integration; wired for capture (#53)
|
||||||
|
capture *captureDeps // nil = capture MCP tool disabled (#55 relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureDeps holds what the MCP `capture` tool (the #55 relay door for
|
||||||
|
// MCP-native harnesses like claude.ai) needs: the use-case, the auth bits
|
||||||
|
// to re-derive the caller's principal from the Bearer header (the chassis
|
||||||
|
// middleware gates but discards the principal), and the origin resolver.
|
||||||
|
type captureDeps struct {
|
||||||
|
svc *capture.Service
|
||||||
|
validator capturehttp.Validator
|
||||||
|
staticToken string
|
||||||
|
staticPrincipal string
|
||||||
|
resolver capturehttp.OriginResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||||
@@ -56,7 +76,13 @@ func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.Compl
|
|||||||
if pipelineCfg != nil {
|
if pipelineCfg != nil {
|
||||||
cfg = *pipelineCfg
|
cfg = *pipelineCfg
|
||||||
}
|
}
|
||||||
return &Server{brainDir: brainDir, pipeline: cfg, llm: llm, answerLLM: answerLLM}
|
return &Server{
|
||||||
|
brainDir: brainDir,
|
||||||
|
pipeline: cfg,
|
||||||
|
llm: llm,
|
||||||
|
answerLLM: answerLLM,
|
||||||
|
store: brainstore.New(brainDir),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithReranker installs an opt-in cross-encoder reranker. When set,
|
// WithReranker installs an opt-in cross-encoder reranker. When set,
|
||||||
@@ -84,9 +110,55 @@ func (s *Server) WithHybridRetrieval(v search.VectorSearcher, e search.Embedder)
|
|||||||
func (s *Server) WithGraph(g *graphstore.PGStore) *Server {
|
func (s *Server) WithGraph(g *graphstore.PGStore) *Server {
|
||||||
if g == nil {
|
if g == nil {
|
||||||
s.graph = nil
|
s.graph = nil
|
||||||
|
s.store.WithGraph(nil)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
s.graph = g
|
s.graph = g
|
||||||
|
s.store.WithGraph(g)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithIssueTracker injects the Gitea ticket tracker behind the
|
||||||
|
// capture.IssueTracker interface. nil leaves ticket integration off. The
|
||||||
|
// use-case (capture) consumes this in #53; it is wired here so the
|
||||||
|
// dependency is constructed once and stays swappable/testable.
|
||||||
|
func (s *Server) WithIssueTracker(t capture.IssueTracker) *Server {
|
||||||
|
s.tracker = t
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueTracker returns the injected ticket tracker (nil when unconfigured).
|
||||||
|
func (s *Server) IssueTracker() capture.IssueTracker {
|
||||||
|
return s.tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrainStore returns the shared brain store (graph-wired once WithGraph
|
||||||
|
// has run), so the capture use-case writes through the exact same
|
||||||
|
// implementation as the MCP handlers.
|
||||||
|
func (s *Server) BrainStore() *brainstore.Store {
|
||||||
|
return s.store
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCapture enables the MCP `capture` tool (#55) — the relay door for
|
||||||
|
// MCP-native harnesses (claude.ai, Crush, Pi, LLM Council) that cannot run
|
||||||
|
// the use-case in-process. It forwards to the same CaptureService as
|
||||||
|
// POST /capture, deriving the caller's principal + origin from the same
|
||||||
|
// auth credentials that gate /mcp. nil svc leaves the tool unregistered.
|
||||||
|
func (s *Server) WithCapture(svc *capture.Service, validator capturehttp.Validator, staticToken, staticPrincipal string, resolver capturehttp.OriginResolver) *Server {
|
||||||
|
if svc == nil {
|
||||||
|
s.capture = nil
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if staticPrincipal == "" {
|
||||||
|
staticPrincipal = "local-cli"
|
||||||
|
}
|
||||||
|
s.capture = &captureDeps{
|
||||||
|
svc: svc,
|
||||||
|
validator: validator,
|
||||||
|
staticToken: staticToken,
|
||||||
|
staticPrincipal: staticPrincipal,
|
||||||
|
resolver: resolver,
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +211,18 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
rpcErr = &rpcError{Code: -32602, Message: "invalid params"}
|
rpcErr = &rpcError{Code: -32602, Message: "invalid params"}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
out, err := s.handleCall(r.Context(), p.Name, p.Arguments)
|
// Re-derive the authenticated principal from the Bearer header so
|
||||||
|
// the capture tool can compute the trust-zone origin. The request
|
||||||
|
// is already gated by BearerMiddleware; this only recovers the
|
||||||
|
// identity that middleware discards.
|
||||||
|
ctx := r.Context()
|
||||||
|
if s.capture != nil {
|
||||||
|
if principal, viaStatic, ok := capturehttp.Authenticate(
|
||||||
|
r, s.capture.staticToken, s.capture.staticPrincipal, s.capture.validator); ok {
|
||||||
|
ctx = withPrincipal(ctx, principal, viaStatic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := s.handleCall(ctx, p.Name, p.Arguments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
||||||
break
|
break
|
||||||
@@ -181,6 +264,12 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainUpdate(ctx, args)
|
return s.brainUpdate(ctx, args)
|
||||||
case "brain_get":
|
case "brain_get":
|
||||||
return s.brainGet(ctx, args)
|
return s.brainGet(ctx, args)
|
||||||
|
case "brain_pending":
|
||||||
|
return s.brainPending(ctx, args)
|
||||||
|
case "brain_promote":
|
||||||
|
return s.brainPromote(ctx, args)
|
||||||
|
case "capture":
|
||||||
|
return s.brainCapture(ctx, args)
|
||||||
case "brain_index":
|
case "brain_index":
|
||||||
return s.brainIndex(ctx, args)
|
return s.brainIndex(ctx, args)
|
||||||
case "brain_tunnel":
|
case "brain_tunnel":
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package mcp_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -56,6 +58,7 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.ElementsMatch(t, []string{
|
assert.ElementsMatch(t, []string{
|
||||||
"brain_query", "brain_write", "brain_update", "brain_get",
|
"brain_query", "brain_write", "brain_update", "brain_get",
|
||||||
|
"brain_pending", "brain_promote",
|
||||||
"brain_index", "brain_tunnel",
|
"brain_index", "brain_tunnel",
|
||||||
"brain_ingest_raw", "brain_ingest",
|
"brain_ingest_raw", "brain_ingest",
|
||||||
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
||||||
@@ -93,3 +96,22 @@ func TestServerUnknownMethodReturnsError(t *testing.T) {
|
|||||||
assert.Equal(t, float64(-32601), errObj["code"])
|
assert.Equal(t, float64(-32601), errObj["code"])
|
||||||
assert.Contains(t, errObj["message"].(string), "unknown/method")
|
assert.Contains(t, errObj["message"].(string), "unknown/method")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stubTracker struct{}
|
||||||
|
|
||||||
|
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithIssueTrackerInjects(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
assert.Nil(t, srv.IssueTracker(), "tracker is off by default")
|
||||||
|
srv = srv.WithIssueTracker(stubTracker{})
|
||||||
|
assert.NotNil(t, srv.IssueTracker(), "tracker injected behind the interface")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// principalKey is the context key under which the authenticated principal
|
||||||
|
// (re-derived in ServeHTTP) is stashed for the capture tool.
|
||||||
|
type principalKeyT struct{}
|
||||||
|
|
||||||
|
var principalKey principalKeyT
|
||||||
|
|
||||||
|
type principalInfo struct {
|
||||||
|
principal string
|
||||||
|
viaStatic bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func withPrincipal(ctx context.Context, principal string, viaStatic bool) context.Context {
|
||||||
|
return context.WithValue(ctx, principalKey, principalInfo{principal: principal, viaStatic: viaStatic})
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureToolDescriptor is the tools/list entry for the capture relay.
|
||||||
|
// Appended only when WithCapture has wired the tool.
|
||||||
|
func captureToolDescriptor() map[string]any {
|
||||||
|
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||||
|
insightItem := map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"text": str("the insight body"), "wing": str("brain wing"),
|
||||||
|
"hall": str("brain hall (facts/decisions/failures/hypotheses/sources)"),
|
||||||
|
"supersede_slug": str("optional: slug of a prior note to revise in place instead of creating"),
|
||||||
|
},
|
||||||
|
"required": []string{"text", "wing", "hall"},
|
||||||
|
}
|
||||||
|
ticketItem := map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"repo": str("gitea repo (owner is always mathias)"), "action": str("create|close|comment"),
|
||||||
|
"number": map[string]any{"type": "integer", "description": "issue number (close/comment)"},
|
||||||
|
"title": str("issue title (create)"), "body": str("issue/comment body"),
|
||||||
|
},
|
||||||
|
"required": []string{"repo", "action"},
|
||||||
|
}
|
||||||
|
schema := map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"context": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"harness": str("descriptive harness label (telemetry only, never a gate input)"),
|
||||||
|
"session_ref": str("optional session reference"), "fidelity": str("live-capture|transcript-parse|agent-runlog"),
|
||||||
|
"actor": str("acting user/agent"), "classification": str("caller-declared sensitivity: public|internal|confidential"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"insights": map[string]any{"type": "array", "items": insightItem},
|
||||||
|
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
||||||
|
"summary": map[string]any{"type": "object", "properties": map[string]any{
|
||||||
|
"title": str("summary title"), "body": str("summary body"),
|
||||||
|
"repos_touched": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||||
|
}},
|
||||||
|
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(schema)
|
||||||
|
return map[string]any{
|
||||||
|
"name": "capture",
|
||||||
|
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets, optional summary → ai-sessions. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
|
||||||
|
"inputSchema": json.RawMessage(b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// brainCapture is the MCP capture tool: the #55 relay for MCP-native
|
||||||
|
// harnesses. It re-uses the same CaptureService, principal-derivation, and
|
||||||
|
// origin resolver as POST /capture — only the transport differs. It holds
|
||||||
|
// no state and retains nothing beyond the I5 audit record.
|
||||||
|
func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
if s.capture == nil {
|
||||||
|
return nil, fmt.Errorf("capture tool not configured")
|
||||||
|
}
|
||||||
|
info, ok := ctx.Value(principalKey).(principalInfo)
|
||||||
|
if !ok || info.principal == "" {
|
||||||
|
// No authenticated principal ⇒ cannot derive origin ⇒ cannot gate.
|
||||||
|
return nil, fmt.Errorf("capture requires an authenticated principal")
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := capturehttp.DecodeRequest(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid capture request: %w", err)
|
||||||
|
}
|
||||||
|
// Principal and origin are server-derived — never taken from the body.
|
||||||
|
in.Context.Principal = info.principal
|
||||||
|
in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic)
|
||||||
|
|
||||||
|
rec, err := s.capture.svc.Capture(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
// Surface I1/I5 refusals and validation failures verbatim; errors.Is
|
||||||
|
// markers (ErrSovereigntyRefused / ErrAuditUnavailable) ride in the message.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(rec)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const capStaticTok = "cap-static-tok"
|
||||||
|
|
||||||
|
type capFakeTracker struct{}
|
||||||
|
|
||||||
|
func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||||
|
}
|
||||||
|
func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type capFakeValidator struct {
|
||||||
|
subject string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v capFakeValidator) Validate(context.Context, string) (string, error) {
|
||||||
|
return v.subject, v.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureServer(t *testing.T, validator capturehttp.Validator, sovereign []string) (*mcp.Server, string) {
|
||||||
|
t.Helper()
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
cfg, err := classification.Load(brainDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, nil, cfg, audit.NewSlogSink(nil))
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||||
|
return srv, brainDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureCall(t *testing.T, srv http.Handler, authz string, args map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||||
|
"params": map[string]any{"name": "capture", "arguments": args},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||||
|
if authz != "" {
|
||||||
|
req.Header.Set("Authorization", authz)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureToolListedWhenWired(t *testing.T) {
|
||||||
|
srv, _ := captureServer(t, nil, nil)
|
||||||
|
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
assert.Contains(t, rr.Body.String(), `"capture"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureToolNotListedByDefault(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil) // no WithCapture
|
||||||
|
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
assert.NotContains(t, rr.Body.String(), `"capture"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureToolForwardsViaStaticPrincipal(t *testing.T) {
|
||||||
|
srv, brainDir := captureServer(t, nil, nil)
|
||||||
|
resp := captureCall(t, srv, "Bearer "+capStaticTok, map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "t"}},
|
||||||
|
})
|
||||||
|
require.Nil(t, resp["error"], "got error: %v", resp["error"])
|
||||||
|
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||||
|
var rec capture.CaptureReceipt
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(text), &rec))
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
// Forwarded to the real brain store.
|
||||||
|
_, statErr := os.Stat(filepath.Join(brainDir, "wiki/hyperguild/facts"))
|
||||||
|
require.NoError(t, statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureToolRefusesConfidentialViaUSNexus(t *testing.T) {
|
||||||
|
// JWT principal not in the sovereign allowlist ⇒ us-nexus origin.
|
||||||
|
srv, _ := captureServer(t, capFakeValidator{subject: "claudeai-oauth"}, nil)
|
||||||
|
resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claudeai-chat", "actor": "mathias", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "sovereignty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureToolAllowsConfidentialViaSovereignJWT(t *testing.T) {
|
||||||
|
srv, _ := captureServer(t, capFakeValidator{subject: "koala-cli"}, []string{"koala-cli"})
|
||||||
|
resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
assert.Nil(t, resp["error"], "sovereign JWT principal should be allowed: %v", resp["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureToolRejectsUnauthenticated(t *testing.T) {
|
||||||
|
srv, _ := captureServer(t, capFakeValidator{err: errors.New("no jwt")}, nil)
|
||||||
|
resp := captureCall(t, srv, "", map[string]any{ // no Authorization
|
||||||
|
"context": map[string]any{"harness": "x", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "a", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "authenticated principal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureToolCallerCannotForgeOrigin(t *testing.T) {
|
||||||
|
// Body asserts sovereign harness, but the us-nexus JWT principal governs.
|
||||||
|
srv, _ := captureServer(t, capFakeValidator{subject: "claudeai-oauth"}, nil)
|
||||||
|
resp := captureCall(t, srv, "Bearer jwt", map[string]any{
|
||||||
|
"context": map[string]any{"harness": "sovereign-soil", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "sovereignty")
|
||||||
|
}
|
||||||
@@ -76,6 +76,15 @@ func buildFrontmatter(rp RawPage, date string) string {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
||||||
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
||||||
|
if rp.Source != "" {
|
||||||
|
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
||||||
|
}
|
||||||
|
if rp.Author != "" {
|
||||||
|
fmt.Fprintf(&sb, "author: %s\n", yamlScalar(rp.Author))
|
||||||
|
}
|
||||||
|
if rp.Published != "" {
|
||||||
|
fmt.Fprintf(&sb, "published: %s\n", yamlScalar(rp.Published))
|
||||||
|
}
|
||||||
case "concept":
|
case "concept":
|
||||||
if rp.Domain != "" {
|
if rp.Domain != "" {
|
||||||
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
||||||
|
|||||||
@@ -154,6 +154,52 @@ func TestBuildPages_EntityNoSubtype(t *testing.T) {
|
|||||||
assert.Contains(t, pages[0].Content, "title: 'Basecamp'")
|
assert.Contains(t, pages[0].Content, "title: 'Basecamp'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildPages_SourcePageCarriesSourceAuthorPublished(t *testing.T) {
|
||||||
|
raw := []RawPage{
|
||||||
|
{
|
||||||
|
Title: "Ornith",
|
||||||
|
Type: "source",
|
||||||
|
Subtype: "article",
|
||||||
|
Content: "## Summary\n\nAn agentic coding model.\n",
|
||||||
|
Source: "https://example.com/ornith",
|
||||||
|
Author: "Jane Doe",
|
||||||
|
Published: "2026-07-20",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
pages, warnings := BuildPages(raw, "ornith", "2026-07-26")
|
||||||
|
require.Len(t, pages, 1)
|
||||||
|
assert.Empty(t, warnings)
|
||||||
|
|
||||||
|
p := pages[0]
|
||||||
|
assert.Contains(t, p.Content, "source: 'https://example.com/ornith'")
|
||||||
|
assert.Contains(t, p.Content, "author: 'Jane Doe'")
|
||||||
|
assert.Contains(t, p.Content, "published: '2026-07-20'")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPages_SourcePageOmitsSourceAuthorPublishedWhenEmpty(t *testing.T) {
|
||||||
|
raw := []RawPage{
|
||||||
|
{Title: "Shape Up", Type: "source", Subtype: "book", Content: "## Summary\n\nA book.\n"},
|
||||||
|
}
|
||||||
|
pages, _ := BuildPages(raw, "shape-up", "2026-04-23")
|
||||||
|
require.Len(t, pages, 1)
|
||||||
|
assert.NotContains(t, pages[0].Content, "source:")
|
||||||
|
assert.NotContains(t, pages[0].Content, "author:")
|
||||||
|
assert.NotContains(t, pages[0].Content, "published:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPages_ConceptPageIgnoresSourceAuthorPublished(t *testing.T) {
|
||||||
|
// source/author/published are source-note-only metadata; a concept page
|
||||||
|
// shouldn't carry them even if somehow set on the RawPage.
|
||||||
|
raw := []RawPage{
|
||||||
|
{Title: "Betting", Type: "concept", Content: "## Definition\n\nFoo.\n", Source: "x", Author: "y", Published: "z"},
|
||||||
|
}
|
||||||
|
pages, _ := BuildPages(raw, "src", "2026-04-23")
|
||||||
|
require.Len(t, pages, 1)
|
||||||
|
assert.NotContains(t, pages[0].Content, "source:")
|
||||||
|
assert.NotContains(t, pages[0].Content, "author:")
|
||||||
|
assert.NotContains(t, pages[0].Content, "published:")
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildPages_EmptyTitleSkippedWithWarning(t *testing.T) {
|
func TestBuildPages_EmptyTitleSkippedWithWarning(t *testing.T) {
|
||||||
raw := []RawPage{
|
raw := []RawPage{
|
||||||
{Title: "", Type: "concept", Content: "## Definition\n\nFoo.\n"},
|
{Title: "", Type: "concept", Content: "## Definition\n\nFoo.\n"},
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ func buildTitleMap(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entry)
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pathStylePrefixes are known root prefixes the LLM extraction step
|
||||||
|
// sometimes bakes into a wikilink target instead of emitting a clean
|
||||||
|
// wing/hall/slug path (or bare title). Stripping them repairs the link
|
||||||
|
// in place — see hyperguild#87.
|
||||||
|
var pathStylePrefixes = []string{"wing:", "wiki/"}
|
||||||
|
|
||||||
|
func stripPathStylePrefix(displayName string) (string, bool) {
|
||||||
|
for _, prefix := range pathStylePrefixes {
|
||||||
|
if stripped, ok := strings.CutPrefix(displayName, prefix); ok {
|
||||||
|
return stripped, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return displayName, false
|
||||||
|
}
|
||||||
|
|
||||||
func canonicalizeContent(content string, titleToSlug map[string]string) (string, []string) {
|
func canonicalizeContent(content string, titleToSlug map[string]string) (string, []string) {
|
||||||
var warnings []string
|
var warnings []string
|
||||||
result := plainLinkRE.ReplaceAllStringFunc(content, func(match string) string {
|
result := plainLinkRE.ReplaceAllStringFunc(content, func(match string) string {
|
||||||
@@ -59,12 +74,17 @@ func canonicalizeContent(content string, titleToSlug map[string]string) (string,
|
|||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
displayName := sub[1]
|
displayName := sub[1]
|
||||||
slug, ok := titleToSlug[strings.ToLower(displayName)]
|
|
||||||
if !ok {
|
if slug, ok := titleToSlug[strings.ToLower(displayName)]; ok {
|
||||||
|
return "[[" + slug + "|" + displayName + "]]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if stripped, hadPrefix := stripPathStylePrefix(displayName); hadPrefix {
|
||||||
|
return "[[" + stripped + "]]"
|
||||||
|
}
|
||||||
|
|
||||||
warnings = append(warnings, fmt.Sprintf("unknown wikilink: [[%s]]", displayName))
|
warnings = append(warnings, fmt.Sprintf("unknown wikilink: [[%s]]", displayName))
|
||||||
return match
|
return match
|
||||||
}
|
|
||||||
return "[[" + slug + "|" + displayName + "]]"
|
|
||||||
})
|
})
|
||||||
return result, warnings
|
return result, warnings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,53 @@ func TestCanonicalizeLinks_CurrentBatchPagesResolved(t *testing.T) {
|
|||||||
assert.Contains(t, got[0].Content, "[[betting|Betting]]")
|
assert.Contains(t, got[0].Content, "[[betting|Betting]]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanonicalizeLinks_StripsWingColonPrefix(t *testing.T) {
|
||||||
|
pages := []wiki.Page{
|
||||||
|
{
|
||||||
|
Path: "wiki/homelab/failures/act-runner-host-mode-container-needs-node-and-libatomic.md",
|
||||||
|
Content: "---\ntitle: 'act_runner host-mode'\n---\n\nSee [[wing:homelab/failures/rootless-buildah-act-runner-run-containers-denied]].\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got, warnings := CanonicalizeLinks(pages, map[wiki.PageType][]wiki.Entry{})
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.Empty(t, warnings)
|
||||||
|
assert.Contains(t, got[0].Content, "[[homelab/failures/rootless-buildah-act-runner-run-containers-denied]]")
|
||||||
|
assert.NotContains(t, got[0].Content, "wing:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalizeLinks_StripsWikiSlashPrefix(t *testing.T) {
|
||||||
|
pages := []wiki.Page{
|
||||||
|
{
|
||||||
|
Path: "wiki/agentsquad/hypotheses/council-consolidation-standalone-deliberation-service.md",
|
||||||
|
Content: "---\ntitle: 'council consolidation'\n---\n\nSee [[wiki/agentsquad/decisions/autoresearch-council-sibling-pipe]].\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got, warnings := CanonicalizeLinks(pages, map[wiki.PageType][]wiki.Entry{})
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.Empty(t, warnings)
|
||||||
|
assert.Contains(t, got[0].Content, "[[agentsquad/decisions/autoresearch-council-sibling-pipe]]")
|
||||||
|
assert.NotContains(t, got[0].Content, "wiki/agentsquad/decisions/autoresearch-council-sibling-pipe]]\n\n") // no leftover wiki/ prefix
|
||||||
|
assert.NotContains(t, got[0].Content, "[[wiki/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalizeLinks_TitleLookupStillTakesPriorityOverPrefixStrip(t *testing.T) {
|
||||||
|
// A plain link that resolves via the title map must still use the
|
||||||
|
// normal slug|Display form, not fall through to prefix-strip repair.
|
||||||
|
pages := []wiki.Page{
|
||||||
|
{
|
||||||
|
Path: "wiki/sources/shape-up.md",
|
||||||
|
Content: "---\ntitle: 'Shape Up'\n---\n\nSee [[Betting]].\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
inventory := map[wiki.PageType][]wiki.Entry{
|
||||||
|
wiki.PageTypeConcept: {{Slug: "betting", Title: "Betting"}},
|
||||||
|
}
|
||||||
|
got, warnings := CanonicalizeLinks(pages, inventory)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.Empty(t, warnings)
|
||||||
|
assert.Contains(t, got[0].Content, "[[betting|Betting]]")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCanonicalizeLinks_MultipleLinksInOnePage(t *testing.T) {
|
func TestCanonicalizeLinks_MultipleLinksInOnePage(t *testing.T) {
|
||||||
pages := []wiki.Page{
|
pages := []wiki.Page{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ type RawPage struct {
|
|||||||
Subtype string `json:"subtype"` // entity: person|company|tool|model|framework|technology; source: article|pdf|book|video|note|project
|
Subtype string `json:"subtype"` // entity: person|company|tool|model|framework|technology; source: article|pdf|book|video|note|project
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
Content string `json:"content"` // Markdown body only — no frontmatter
|
Content string `json:"content"` // Markdown body only — no frontmatter
|
||||||
|
|
||||||
|
// Source, Author, Published are deterministic passthrough from the raw
|
||||||
|
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
||||||
|
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
||||||
|
// might emit. Only meaningful for Type == "source".
|
||||||
|
Source string `json:"-"`
|
||||||
|
Author string `json:"-"`
|
||||||
|
Published string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseRawPages parses LLM output as a JSON array of RawPage objects.
|
// ParseRawPages parses LLM output as a JSON array of RawPage objects.
|
||||||
@@ -98,6 +106,60 @@ func repairJSON(s string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sourceMeta is source/author/published pulled from the raw ingested
|
||||||
|
// content's own frontmatter — deterministic passthrough, never LLM output.
|
||||||
|
type sourceMeta struct {
|
||||||
|
Source string
|
||||||
|
Author string
|
||||||
|
Published string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContentFrontmatter extracts source/author/published from a leading
|
||||||
|
// "---\n...\n---" YAML block in raw ingested content. Only these three flat
|
||||||
|
// scalar keys are recognised; anything else in the block is ignored. Returns
|
||||||
|
// a zero-value sourceMeta if content has no frontmatter block.
|
||||||
|
func parseContentFrontmatter(content string) sourceMeta {
|
||||||
|
var meta sourceMeta
|
||||||
|
if !strings.HasPrefix(content, "---\n") && !strings.HasPrefix(content, "---\r\n") {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
for _, line := range lines[1:] {
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
val = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
switch key {
|
||||||
|
case "source":
|
||||||
|
meta.Source = val
|
||||||
|
case "author":
|
||||||
|
meta.Author = val
|
||||||
|
case "published":
|
||||||
|
meta.Published = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySourceMeta deterministically overwrites Source/Author/Published on
|
||||||
|
// every "source"-type page with meta — the LLM never controls these fields.
|
||||||
|
func applySourceMeta(pages []RawPage, meta sourceMeta) {
|
||||||
|
for i := range pages {
|
||||||
|
if pages[i].Type != "source" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pages[i].Source = meta.Source
|
||||||
|
pages[i].Author = meta.Author
|
||||||
|
pages[i].Published = meta.Published
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func stripFences(s string) string {
|
func stripFences(s string) string {
|
||||||
for _, prefix := range []string{"```json\n", "```json\r\n", "```\n", "```\r\n"} {
|
for _, prefix := range []string{"```json\n", "```json\r\n", "```\n", "```\r\n"} {
|
||||||
if strings.HasPrefix(s, prefix) {
|
if strings.HasPrefix(s, prefix) {
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
allWarnings = append(allWarnings, warnings...)
|
allWarnings = append(allWarnings, warnings...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applySourceMeta(allRaw, parseContentFrontmatter(content))
|
||||||
|
|
||||||
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,40 @@ func TestRun_MergesDuplicatePaths(t *testing.T) {
|
|||||||
assert.Contains(t, string(content), "[[Baz]]")
|
assert.Contains(t, string(content), "[[Baz]]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRun_ThreadsSourceAuthorPublishedFromContentFrontmatter(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, sub), 0o755))
|
||||||
|
}
|
||||||
|
|
||||||
|
llmResponse := mustJSON([]RawPage{{
|
||||||
|
Title: "Ornith",
|
||||||
|
Type: "source",
|
||||||
|
Subtype: "article",
|
||||||
|
Content: "## Summary\n\nAn agentic coding model.\n",
|
||||||
|
}})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{{"message": map[string]any{"content": llmResponse}}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := Config{Complete: llm.New(srv.URL, "", "m", 30*time.Second).Complete}
|
||||||
|
rawContent := "---\nsource: https://example.com/ornith\nauthor: Jane Doe\npublished: 2026-07-20\n---\n\nAn agentic coding model that runs on your laptop.\n"
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), cfg, brainDir, rawContent, "ornith", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result.Pages, 1)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", "ornith.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(content), "source: 'https://example.com/ornith'")
|
||||||
|
assert.Contains(t, string(content), "author: 'Jane Doe'")
|
||||||
|
assert.Contains(t, string(content), "published: '2026-07-20'")
|
||||||
|
}
|
||||||
|
|
||||||
func mustJSON(v any) string {
|
func mustJSON(v any) string {
|
||||||
b, err := json.Marshal(v)
|
b, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutoTunnel's own fuzzy-match human-review queue, not source
|
||||||
|
// material to extract (hyperguild#88) — same exclusion as
|
||||||
|
// api.ListPending already applies when listing raw/ for promotion.
|
||||||
|
if strings.HasPrefix(d.Name(), "tunnel-candidates-") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Skip files that have already been processed or permanently failed.
|
// Skip files that have already been processed or permanently failed.
|
||||||
if _, err := os.Stat(path + ".processed"); err == nil {
|
if _, err := os.Stat(path + ".processed"); err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -229,3 +229,49 @@ func TestProcessDir_SkipsSubdirs(t *testing.T) {
|
|||||||
_, err = os.Stat(failedFile)
|
_, err = os.Stat(failedFile)
|
||||||
assert.NoError(t, err, "failed subdir file should be untouched")
|
assert.NoError(t, err, "failed subdir file should be untouched")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessDir_SkipsTunnelCandidateFiles guards against hyperguild#88:
|
||||||
|
// AutoTunnel's own human-review queue (brain/raw/tunnel-candidates-*.md)
|
||||||
|
// must never be re-ingested as if it were external source material — doing
|
||||||
|
// so fed the raw "(term: X)" log entries back through the LLM extraction
|
||||||
|
// pipeline, which then legitimately (from its own perspective) surfaced
|
||||||
|
// [[X]] as a wikilink for any term that happened to match a real page
|
||||||
|
// title, however generic (e.g. "mission").
|
||||||
|
func TestProcessDir_SkipsTunnelCandidateFiles(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
|
||||||
|
tunnelFile := filepath.Join(brainDir, "raw", "tunnel-candidates-2026-07-19.md")
|
||||||
|
require.NoError(t, os.WriteFile(tunnelFile, []byte(
|
||||||
|
"# Tunnel candidates 2026-07-19\n\n- `wiki/homelab/decisions/foo.md` ↔ `wiki/telos/decisions/mission.md` (term: \"mission\")\n",
|
||||||
|
), 0o644))
|
||||||
|
|
||||||
|
var completeCalls int
|
||||||
|
completeFn := func(ctx context.Context, system, user string) (string, error) {
|
||||||
|
completeCalls++
|
||||||
|
raw := pipeline.RawPage{Title: "Should not be written", Type: "source", Subtype: "article", Content: "## Summary\n\nx.\n"}
|
||||||
|
b, _ := json.Marshal([]pipeline.RawPage{raw})
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
BrainDir: brainDir,
|
||||||
|
Interval: time.Hour, // not used; we call processDir directly
|
||||||
|
Pipeline: pipeline.Config{
|
||||||
|
Complete: completeFn,
|
||||||
|
ChunkSize: 0,
|
||||||
|
Schema: "# Schema\nThree page types.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
errs := processDir(context.Background(), cfg, date)
|
||||||
|
assert.Empty(t, errs)
|
||||||
|
|
||||||
|
assert.Zero(t, completeCalls, "tunnel-candidates file must never reach the LLM extraction pipeline")
|
||||||
|
|
||||||
|
// File must be left alone in raw/ — not moved to processed/, no marker written.
|
||||||
|
_, err := os.Stat(tunnelFile + ".processed")
|
||||||
|
assert.True(t, os.IsNotExist(err), "tunnel-candidates file should not get a .processed marker")
|
||||||
|
_, err = os.Stat(filepath.Join(brainDir, "raw", "processed", date, "tunnel-candidates-2026-07-19.md"))
|
||||||
|
assert.True(t, os.IsNotExist(err), "tunnel-candidates file should not be copied to processed/")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
"github.com/mathiasbq/supervisor/internal/auth"
|
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/auth"
|
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/brain"
|
"git.d-ma.be/mathias/hyperguild/internal/brain"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package config_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/config"
|
"git.d-ma.be/mathias/hyperguild/internal/config"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/config"
|
"git.d-ma.be/mathias/hyperguild/internal/config"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package config_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/config"
|
"git.d-ma.be/mathias/hyperguild/internal/config"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
iexec "git.d-ma.be/mathias/hyperguild/internal/exec"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/githubclient"
|
"git.d-ma.be/mathias/hyperguild/internal/githubclient"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/auth"
|
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type request struct {
|
type request struct {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/mcp"
|
"git.d-ma.be/mathias/hyperguild/internal/mcp"
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
"git.d-ma.be/mathias/hyperguild/internal/mcpclient"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
"git.d-ma.be/mathias/hyperguild/internal/session"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
"git.d-ma.be/mathias/hyperguild/internal/session"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/brain"
|
"git.d-ma.be/mathias/hyperguild/internal/skills/brain"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package brain
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds brain skill configuration.
|
// Config holds brain skill configuration.
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/org"
|
"git.d-ma.be/mathias/hyperguild/internal/skills/org"
|
||||||
"github.com/mathiasbq/supervisor/internal/tier"
|
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||||
"github.com/mathiasbq/supervisor/internal/tier"
|
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TierFn returns the current tier. Injected for testability.
|
// TierFn returns the current tier. Injected for testability.
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user