Compare commits
64 Commits
feature/ch
...
f6a86e3933
| Author | SHA1 | Date | |
|---|---|---|---|
| f6a86e3933 | |||
| a4193b295a | |||
| b17241b928 | |||
| 63f8dcacb6 | |||
| 927a118338 | |||
| b39cd3ba72 | |||
| db7b152852 | |||
| da7220ad64 | |||
| 71845d38d3 | |||
| bdc78cc2a3 | |||
| 6c5e3918dc | |||
| cd14be0666 | |||
| 20122a5f53 | |||
|
|
8e74dc9284 | ||
| 1c9741373e | |||
| 3e832eea98 | |||
| c4d260d971 | |||
| 27cd494f6d | |||
| 3dfed9c986 | |||
| 512b16f8fe | |||
| 9a9fa4f384 | |||
| 979afc909e | |||
| 55859811be | |||
| 7f9c6f122e | |||
| 14e218d784 | |||
| 532d1560a3 | |||
| 894fa3fc1d | |||
| a6165a0f2e | |||
| b6e156011f | |||
| 4d107cb87e | |||
| 1ed67881e6 | |||
| 1d4dbad33f | |||
| 02bcbdabd8 | |||
| 5f48a197e8 | |||
| 1958eaca01 | |||
| 4aed4105aa | |||
| 8af4956951 | |||
| 5457cbbd21 | |||
| d6488cd4d5 | |||
| a1bf5ceb38 | |||
| 28f7dc199e | |||
|
|
73eb852361 | ||
|
|
a42274a770 | ||
| f0d9c4dc09 | |||
|
|
4bf1c1fe60 | ||
|
|
6c6f4022a0 | ||
| 1328b3cc94 | |||
| 87a62c0d6c | |||
| 3e09dc0ac6 | |||
| b59e02aebe | |||
| 4713110e32 | |||
| 6c6b49b45c | |||
| 59c43188e5 | |||
| f0e242293f | |||
|
|
50870dd369 | ||
| b93f1d14f0 | |||
|
|
7c41a3e846 | ||
|
|
d1d140e464 | ||
|
|
9cfcb5621b | ||
|
|
d0bfdbfbab | ||
| 24532ef380 | |||
| 9407c05535 | |||
| f163b9c370 | |||
| 4fdd1411b2 |
44
.gitea/workflows/ci.yml
Normal file
44
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
tags-ignore: ['v*']
|
||||||
|
pull_request:
|
||||||
|
branches: ['**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26'
|
||||||
|
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/go-build
|
||||||
|
~/go/pkg/mod
|
||||||
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Tidy modules
|
||||||
|
run: go mod tidy
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Build amcs-server
|
||||||
|
run: go build -o /dev/null ./cmd/amcs-server
|
||||||
|
|
||||||
|
- name: Build amcs-cli
|
||||||
|
run: go build -o /dev/null ./cmd/amcs-cli
|
||||||
122
.gitea/workflows/release.yml
Normal file
122
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to release (e.g. v1.2.3)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_SERVER: https://git.warky.dev
|
||||||
|
GITEA_REPO: wdevs/amcs
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26'
|
||||||
|
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/go-build
|
||||||
|
~/go/pkg/mod
|
||||||
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm
|
||||||
|
|
||||||
|
- name: Build UI
|
||||||
|
run: |
|
||||||
|
cd ui
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
- name: Set build vars
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||||
|
echo "VERSION=${TAG}" >> $GITHUB_OUTPUT
|
||||||
|
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
echo "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build release binaries
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.vars.outputs.VERSION }}"
|
||||||
|
COMMIT="${{ steps.vars.outputs.COMMIT }}"
|
||||||
|
BUILD_DATE="${{ steps.vars.outputs.BUILD_DATE }}"
|
||||||
|
LDFLAGS="-s -w -X git.warky.dev/wdevs/amcs/internal/buildinfo.Version=${VERSION} -X git.warky.dev/wdevs/amcs/internal/buildinfo.TagName=${VERSION} -X git.warky.dev/wdevs/amcs/internal/buildinfo.Commit=${COMMIT} -X git.warky.dev/wdevs/amcs/internal/buildinfo.BuildDate=${BUILD_DATE}"
|
||||||
|
mkdir -p dist
|
||||||
|
for BINARY in amcs-server amcs-cli; do
|
||||||
|
CMD="./cmd/${BINARY}"
|
||||||
|
for PLATFORM in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
|
||||||
|
OS="${PLATFORM%/*}"
|
||||||
|
ARCH="${PLATFORM#*/}"
|
||||||
|
EXT=""
|
||||||
|
[ "$OS" = "windows" ] && EXT=".exe"
|
||||||
|
OUTPUT="dist/${BINARY}-${OS}-${ARCH}${EXT}"
|
||||||
|
echo "Building ${OUTPUT}..."
|
||||||
|
GOOS=$OS GOARCH=$ARCH go build -ldflags "${LDFLAGS}" -o "${OUTPUT}" "${CMD}"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
cd dist && sha256sum * > checksums.txt && cd ..
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
id: create_release
|
||||||
|
run: |
|
||||||
|
export VERSION="${{ steps.vars.outputs.VERSION }}"
|
||||||
|
BODY=$(python3 <<'PY'
|
||||||
|
import json, subprocess, os
|
||||||
|
version = os.environ['VERSION']
|
||||||
|
commit = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'], text=True).strip()
|
||||||
|
body = f"## {version}\n\nBuilt from commit {commit}.\n\nSee `checksums.txt` to verify downloads."
|
||||||
|
print(json.dumps({
|
||||||
|
'tag_name': version,
|
||||||
|
'name': version,
|
||||||
|
'body': body,
|
||||||
|
'draft': False,
|
||||||
|
'prerelease': False,
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
RESPONSE=$(curl -fsS -X POST "${{ env.GITEA_SERVER }}/api/v1/repos/${{ env.GITEA_REPO }}/releases" \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$BODY")
|
||||||
|
RELEASE_ID=$(printf '%s' "$RESPONSE" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
|
||||||
|
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
run: |
|
||||||
|
RELEASE_ID="${{ steps.create_release.outputs.RELEASE_ID }}"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "Uploading ${name}..."
|
||||||
|
curl -fsS -X POST \
|
||||||
|
"${{ env.GITEA_SERVER }}/api/v1/repos/${{ env.GITEA_REPO }}/releases/${RELEASE_ID}/assets?name=${name}" \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}"
|
||||||
|
done
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -31,3 +31,7 @@ cmd/amcs-server/__debug_*
|
|||||||
bin/
|
bin/
|
||||||
.cache/
|
.cache/
|
||||||
OB1/
|
OB1/
|
||||||
|
ui/node_modules/
|
||||||
|
ui/.svelte-kit/
|
||||||
|
internal/app/ui/dist/
|
||||||
|
.codex
|
||||||
|
|||||||
22
Dockerfile
22
Dockerfile
@@ -1,3 +1,14 @@
|
|||||||
|
FROM node:22-bookworm AS ui-builder
|
||||||
|
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
WORKDIR /src/ui
|
||||||
|
|
||||||
|
COPY ui/package.json ui/pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY ui/ ./
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
FROM golang:1.26.1-bookworm AS builder
|
FROM golang:1.26.1-bookworm AS builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -6,6 +17,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=ui-builder /src/internal/app/ui/dist ./internal/app/ui/dist
|
||||||
|
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
VERSION_TAG="$(git describe --tags --exact-match 2>/dev/null || echo dev)"; \
|
VERSION_TAG="$(git describe --tags --exact-match 2>/dev/null || echo dev)"; \
|
||||||
@@ -17,7 +29,14 @@ RUN set -eu; \
|
|||||||
-X git.warky.dev/wdevs/amcs/internal/buildinfo.TagName=${VERSION_TAG} \
|
-X git.warky.dev/wdevs/amcs/internal/buildinfo.TagName=${VERSION_TAG} \
|
||||||
-X git.warky.dev/wdevs/amcs/internal/buildinfo.Commit=${COMMIT_SHA} \
|
-X git.warky.dev/wdevs/amcs/internal/buildinfo.Commit=${COMMIT_SHA} \
|
||||||
-X git.warky.dev/wdevs/amcs/internal/buildinfo.BuildDate=${BUILD_DATE}" \
|
-X git.warky.dev/wdevs/amcs/internal/buildinfo.BuildDate=${BUILD_DATE}" \
|
||||||
-o /out/amcs-server ./cmd/amcs-server
|
-o /out/amcs-server ./cmd/amcs-server; \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -trimpath \
|
||||||
|
-ldflags="-s -w \
|
||||||
|
-X git.warky.dev/wdevs/amcs/internal/buildinfo.Version=${VERSION_TAG} \
|
||||||
|
-X git.warky.dev/wdevs/amcs/internal/buildinfo.TagName=${VERSION_TAG} \
|
||||||
|
-X git.warky.dev/wdevs/amcs/internal/buildinfo.Commit=${COMMIT_SHA} \
|
||||||
|
-X git.warky.dev/wdevs/amcs/internal/buildinfo.BuildDate=${BUILD_DATE}" \
|
||||||
|
-o /out/amcs-migrate-config ./cmd/amcs-migrate-config
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
@@ -29,6 +48,7 @@ RUN apt-get update \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /out/amcs-server /app/amcs-server
|
COPY --from=builder /out/amcs-server /app/amcs-server
|
||||||
|
COPY --from=builder /out/amcs-migrate-config /app/amcs-migrate-config
|
||||||
COPY --chown=appuser:appuser configs /app/configs
|
COPY --chown=appuser:appuser configs /app/configs
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|||||||
74
Makefile
74
Makefile
@@ -3,25 +3,60 @@ GO_CACHE_DIR := $(CURDIR)/.cache/go-build
|
|||||||
SERVER_BIN := $(BIN_DIR)/amcs-server
|
SERVER_BIN := $(BIN_DIR)/amcs-server
|
||||||
CMD_SERVER := ./cmd/amcs-server
|
CMD_SERVER := ./cmd/amcs-server
|
||||||
BUILDINFO_PKG := git.warky.dev/wdevs/amcs/internal/buildinfo
|
BUILDINFO_PKG := git.warky.dev/wdevs/amcs/internal/buildinfo
|
||||||
|
UI_DIR := $(CURDIR)/ui
|
||||||
PATCH_INCREMENT ?= 1
|
PATCH_INCREMENT ?= 1
|
||||||
VERSION_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev)
|
VERSION_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev)
|
||||||
COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
RELSPEC ?= $(shell command -v relspec 2>/dev/null || echo $(HOME)/go/bin/relspec)
|
||||||
|
SCHEMA_FILES := $(sort $(wildcard schema/*.dbml))
|
||||||
|
MERGE_TARGET_TMP := $(CURDIR)/.cache/schema.merge-target.dbml
|
||||||
|
GENERATED_SCHEMA_MIGRATION := migrations/020_generated_schema.sql
|
||||||
|
GENERATED_MODELS_DIR := internal/generatedmodels
|
||||||
|
PNPM ?= pnpm
|
||||||
LDFLAGS := -s -w \
|
LDFLAGS := -s -w \
|
||||||
-X $(BUILDINFO_PKG).Version=$(VERSION_TAG) \
|
-X $(BUILDINFO_PKG).Version=$(VERSION_TAG) \
|
||||||
-X $(BUILDINFO_PKG).TagName=$(VERSION_TAG) \
|
-X $(BUILDINFO_PKG).TagName=$(VERSION_TAG) \
|
||||||
-X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \
|
-X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \
|
||||||
-X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE)
|
-X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE)
|
||||||
|
|
||||||
.PHONY: all build clean migrate release-version test
|
.PHONY: all build clean migrate release-version test generate-migrations generate-models check-schema-drift build-cli ui-install ui-build ui-dev ui-check help
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
build:
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " build Build server binary (includes UI build)"
|
||||||
|
@echo " build-cli Build CLI binary"
|
||||||
|
@echo " test Run all tests (includes UI check)"
|
||||||
|
@echo " clean Remove build artifacts"
|
||||||
|
@echo " migrate Run database migrations"
|
||||||
|
@echo " release-version Tag and push a new patch release (PATCH_INCREMENT=N)"
|
||||||
|
@echo " generate-migrations Generate SQL migration from DBML schema files"
|
||||||
|
@echo " generate-models Generate Go models from DBML schema"
|
||||||
|
@echo " check-schema-drift Verify generated migration matches current schema"
|
||||||
|
@echo " ui-install Install UI dependencies"
|
||||||
|
@echo " ui-build Build UI assets"
|
||||||
|
@echo " ui-dev Start UI dev server"
|
||||||
|
@echo " ui-check Run UI type checks"
|
||||||
|
|
||||||
|
build: ui-build
|
||||||
@mkdir -p $(BIN_DIR)
|
@mkdir -p $(BIN_DIR)
|
||||||
go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER)
|
go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER)
|
||||||
|
|
||||||
test:
|
ui-install:
|
||||||
|
cd $(UI_DIR) && $(PNPM) install --frozen-lockfile
|
||||||
|
|
||||||
|
ui-build: ui-install
|
||||||
|
cd $(UI_DIR) && $(PNPM) run build
|
||||||
|
|
||||||
|
ui-dev: ui-install
|
||||||
|
cd $(UI_DIR) && $(PNPM) run dev
|
||||||
|
|
||||||
|
ui-check: ui-install
|
||||||
|
cd $(UI_DIR) && $(PNPM) run check
|
||||||
|
|
||||||
|
test: ui-check
|
||||||
@mkdir -p $(GO_CACHE_DIR)
|
@mkdir -p $(GO_CACHE_DIR)
|
||||||
GOCACHE=$(GO_CACHE_DIR) go test ./...
|
GOCACHE=$(GO_CACHE_DIR) go test ./...
|
||||||
|
|
||||||
@@ -43,6 +78,7 @@ release-version:
|
|||||||
exit 1; \
|
exit 1; \
|
||||||
fi; \
|
fi; \
|
||||||
git tag -a "$$next_tag" -m "Release $$next_tag"; \
|
git tag -a "$$next_tag" -m "Release $$next_tag"; \
|
||||||
|
git push origin "$$next_tag"; \
|
||||||
echo "$$next_tag"
|
echo "$$next_tag"
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
@@ -50,3 +86,35 @@ migrate:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BIN_DIR)
|
rm -rf $(BIN_DIR)
|
||||||
|
|
||||||
|
generate-migrations:
|
||||||
|
@test -n "$(SCHEMA_FILES)" || (echo "No DBML schema files found in schema/" >&2; exit 1)
|
||||||
|
@command -v $(RELSPEC) >/dev/null 2>&1 || (echo "relspec not found; install git.warky.dev/wdevs/relspecgo/cmd/relspec@latest" >&2; exit 1)
|
||||||
|
@mkdir -p $(dir $(MERGE_TARGET_TMP))
|
||||||
|
@: > $(MERGE_TARGET_TMP)
|
||||||
|
@schema_list=$$(printf '%s\n' $(SCHEMA_FILES) | paste -sd, -); \
|
||||||
|
$(RELSPEC) merge --target dbml --target-path $(MERGE_TARGET_TMP) --source dbml --from-list "$$schema_list" --output pgsql --output-path $(GENERATED_SCHEMA_MIGRATION)
|
||||||
|
|
||||||
|
generate-models:
|
||||||
|
@test -n "$(SCHEMA_FILES)" || (echo "No DBML schema files found in schema/" >&2; exit 1)
|
||||||
|
@./scripts/generate-models.sh
|
||||||
|
|
||||||
|
check-schema-drift:
|
||||||
|
@test -f $(GENERATED_SCHEMA_MIGRATION) || (echo "$(GENERATED_SCHEMA_MIGRATION) is missing; run make generate-migrations" >&2; exit 1)
|
||||||
|
@command -v $(RELSPEC) >/dev/null 2>&1 || (echo "relspec not found; install git.warky.dev/wdevs/relspecgo/cmd/relspec@latest" >&2; exit 1)
|
||||||
|
@mkdir -p $(dir $(MERGE_TARGET_TMP))
|
||||||
|
@tmpfile=$$(mktemp); \
|
||||||
|
: > $(MERGE_TARGET_TMP); \
|
||||||
|
schema_list=$$(printf '%s\n' $(SCHEMA_FILES) | paste -sd, -); \
|
||||||
|
$(RELSPEC) merge --target dbml --target-path $(MERGE_TARGET_TMP) --source dbml --from-list "$$schema_list" --output pgsql --output-path $$tmpfile; \
|
||||||
|
if ! cmp -s $$tmpfile $(GENERATED_SCHEMA_MIGRATION); then \
|
||||||
|
echo "Schema drift detected between schema/*.dbml and $(GENERATED_SCHEMA_MIGRATION)" >&2; \
|
||||||
|
diff -u $(GENERATED_SCHEMA_MIGRATION) $$tmpfile || true; \
|
||||||
|
rm -f $$tmpfile; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
rm -f $$tmpfile
|
||||||
|
|
||||||
|
build-cli:
|
||||||
|
@mkdir -p $(BIN_DIR)
|
||||||
|
go build -o $(BIN_DIR)/amcs-cli ./cmd/amcs-cli
|
||||||
|
|||||||
260
README.md
260
README.md
@@ -1,24 +1,18 @@
|
|||||||
# Avalon Memory Crystal Server (amcs)
|
# AMCS Directory
|
||||||
|
|
||||||

|
This is the AMCS (Avalon Memory Control Service) directory.
|
||||||
|
|
||||||
A Go MCP server for capturing and retrieving thoughts, memory, and project context. Exposes tools over Streamable HTTP, backed by Postgres with pgvector for semantic search.
|
## Purpose
|
||||||
|
|
||||||
## What it does
|
The AMCS directory is used to store configuration and code for the Avalon Memory Control Service, which handles...
|
||||||
|
|
||||||
- **Capture** thoughts with automatic embedding and metadata extraction
|
## Structure
|
||||||
- **Search** thoughts semantically via vector similarity
|
|
||||||
- **Organise** thoughts into projects and retrieve full project context
|
|
||||||
- **Summarise** and recall memory across topics and time windows
|
|
||||||
- **Link** related thoughts and traverse relationships
|
|
||||||
|
|
||||||
## Stack
|
- `configs/` - Configuration files
|
||||||
|
- `scripts/` - Scripts for managing the system
|
||||||
|
- `assets/` - Asset files
|
||||||
|
|
||||||
- Go — MCP server over Streamable HTTP
|
## Next Steps
|
||||||
- Postgres + pgvector — storage and vector search
|
|
||||||
- LiteLLM — primary hosted AI provider (embeddings + metadata extraction)
|
|
||||||
- OpenRouter — default upstream behind LiteLLM
|
|
||||||
- Ollama — supported local or self-hosted OpenAI-compatible provider
|
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
@@ -37,6 +31,9 @@ A Go MCP server for capturing and retrieving thoughts, memory, and project conte
|
|||||||
| `get_project_context` | Recent + semantic context for a project; uses explicit `project` or the active session project |
|
| `get_project_context` | Recent + semantic context for a project; uses explicit `project` or the active session project |
|
||||||
| `set_active_project` | Set session project scope; requires a stateful MCP session |
|
| `set_active_project` | Set session project scope; requires a stateful MCP session |
|
||||||
| `get_active_project` | Get current session project |
|
| `get_active_project` | Get current session project |
|
||||||
|
| `add_learning` | Create a curated learning record distinct from raw thoughts |
|
||||||
|
| `get_learning` | Retrieve a structured learning by ID |
|
||||||
|
| `list_learnings` | List structured learnings by project/category/area/status/priority/tag/query |
|
||||||
| `summarize_thoughts` | LLM prose summary over a filtered set |
|
| `summarize_thoughts` | LLM prose summary over a filtered set |
|
||||||
| `recall_context` | Semantic + recency context block for injection |
|
| `recall_context` | Semantic + recency context block for injection |
|
||||||
| `link_thoughts` | Create a typed relationship between thoughts |
|
| `link_thoughts` | Create a typed relationship between thoughts |
|
||||||
@@ -46,21 +43,66 @@ A Go MCP server for capturing and retrieving thoughts, memory, and project conte
|
|||||||
| `load_file` | Retrieve a stored file by ID; returns metadata, base64 content, and an embedded MCP binary resource |
|
| `load_file` | Retrieve a stored file by ID; returns metadata, base64 content, and an embedded MCP binary resource |
|
||||||
| `list_files` | Browse stored files by thought, project, or kind |
|
| `list_files` | Browse stored files by thought, project, or kind |
|
||||||
| `backfill_embeddings` | Generate missing embeddings for stored thoughts |
|
| `backfill_embeddings` | Generate missing embeddings for stored thoughts |
|
||||||
| `reparse_thought_metadata` | Re-extract and normalize metadata for stored thoughts |
|
| `reparse_thought_metadata` | Re-extract metadata from thought content |
|
||||||
| `retry_failed_metadata` | Retry metadata extraction for thoughts still pending or failed |
|
| `retry_failed_metadata` | Retry pending/failed metadata extraction |
|
||||||
| `add_skill` | Store a reusable agent skill (behavioural instruction or capability prompt) |
|
| `add_maintenance_task` | Create a recurring or one-time home maintenance task |
|
||||||
|
| `log_maintenance` | Log completed maintenance; updates next due date |
|
||||||
|
| `get_upcoming_maintenance` | List maintenance tasks due within the next N days |
|
||||||
|
| `search_maintenance_history` | Search the maintenance log by task name, category, or date range |
|
||||||
|
| `save_chat_history` | Save chat messages with optional title, summary, channel, agent, and project |
|
||||||
|
| `get_chat_history` | Fetch chat history by UUID or session_id |
|
||||||
|
| `list_chat_histories` | List chat histories; filter by project, channel, agent_id, session_id, or days |
|
||||||
|
| `delete_chat_history` | Delete a chat history by id |
|
||||||
|
| `add_skill` | Store an agent skill (instruction or capability prompt) |
|
||||||
| `remove_skill` | Delete an agent skill by id |
|
| `remove_skill` | Delete an agent skill by id |
|
||||||
| `list_skills` | List all agent skills, optionally filtered by tag |
|
| `list_skills` | List all agent skills, optionally filtered by tag |
|
||||||
| `add_guardrail` | Store a reusable agent guardrail (constraint or safety rule) |
|
| `add_guardrail` | Store an agent guardrail (constraint or safety rule) |
|
||||||
| `remove_guardrail` | Delete an agent guardrail by id |
|
| `remove_guardrail` | Delete an agent guardrail by id |
|
||||||
| `list_guardrails` | List all agent guardrails, optionally filtered by tag or severity |
|
| `list_guardrails` | List all agent guardrails, optionally filtered by tag or severity |
|
||||||
| `add_project_skill` | Link an agent skill to a project; pass `project` explicitly if your client does not preserve MCP sessions |
|
| `add_project_skill` | Link a skill to a project; pass `project` if client is stateless |
|
||||||
| `remove_project_skill` | Unlink an agent skill from a project; pass `project` explicitly if your client does not preserve MCP sessions |
|
| `remove_project_skill` | Unlink a skill from a project; pass `project` if client is stateless |
|
||||||
| `list_project_skills` | List all skills linked to a project; pass `project` explicitly if your client does not preserve MCP sessions |
|
| `list_project_skills` | Skills for a project; pass `project` if client is stateless |
|
||||||
| `add_project_guardrail` | Link an agent guardrail to a project; pass `project` explicitly if your client does not preserve MCP sessions |
|
| `add_project_guardrail` | Link a guardrail to a project; pass `project` if client is stateless |
|
||||||
| `remove_project_guardrail` | Unlink an agent guardrail from a project; pass `project` explicitly if your client does not preserve MCP sessions |
|
| `remove_project_guardrail` | Unlink a guardrail from a project; pass `project` if client is stateless |
|
||||||
| `list_project_guardrails` | List all guardrails linked to a project; pass `project` explicitly if your client does not preserve MCP sessions |
|
| `list_project_guardrails` | Guardrails for a project; pass `project` if client is stateless |
|
||||||
| `get_version_info` | Return the server build version information, including version, tag name, commit, and build date |
|
| `get_version_info` | Build version, commit, and date |
|
||||||
|
| `describe_tools` | List all available MCP tools with names, descriptions, categories, and model-authored usage notes; call this at the start of a session to orient yourself |
|
||||||
|
| `annotate_tool` | Persist your own usage notes for a specific tool; notes are returned by `describe_tools` in future sessions |
|
||||||
|
|
||||||
|
## Learnings
|
||||||
|
|
||||||
|
Learnings are curated, structured memory records for durable insights you want to keep distinct from raw thoughts. Use them for normalized lessons, decisions, and evidence-backed findings that should be easy to retrieve and review over time.
|
||||||
|
|
||||||
|
Compared with `capture_thought`, learnings are more explicit and reviewable: they include a required `summary`, optional `details`, and structured fields like `category`, `area`, `status`, `priority`, `confidence`, and `tags`, plus optional links to a `project`, `related_thought_id`, or `related_skill_id`.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- `add_learning` to create a curated learning.
|
||||||
|
- `get_learning` to fetch one by ID.
|
||||||
|
- `list_learnings` to filter curated learnings across project and status dimensions.
|
||||||
|
|
||||||
|
## Self-Documenting Tools
|
||||||
|
|
||||||
|
AMCS includes a built-in tool directory that models can read and annotate.
|
||||||
|
|
||||||
|
**`describe_tools`** returns every registered tool with its name, description, category, and any model-written notes. Call it with no arguments to get the full list, or filter by category:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "category": "thoughts" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Available categories: `system`, `thoughts`, `projects`, `files`, `admin`, `maintenance`, `skills`, `chat`, `meta`.
|
||||||
|
|
||||||
|
**`annotate_tool`** lets a model write persistent usage notes against a tool name. Notes survive across sessions and are returned by `describe_tools`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "tool_name": "capture_thought", "notes": "Always pass project explicitly — session state is not reliable in this client." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass an empty string to clear notes. The intended workflow is:
|
||||||
|
|
||||||
|
1. At the start of a session, call `describe_tools` to discover tools and read accumulated notes.
|
||||||
|
2. As you learn something non-obvious about a tool — a gotcha, a workflow pattern, a required field ordering — call `annotate_tool` to record it.
|
||||||
|
3. Future sessions receive the annotation automatically via `describe_tools`.
|
||||||
|
|
||||||
## MCP Error Contract
|
## MCP Error Contract
|
||||||
|
|
||||||
@@ -210,12 +252,25 @@ Link existing skills and guardrails to a project so they are automatically avail
|
|||||||
Config is YAML-driven. Copy `configs/config.example.yaml` and set:
|
Config is YAML-driven. Copy `configs/config.example.yaml` and set:
|
||||||
|
|
||||||
- `database.url` — Postgres connection string
|
- `database.url` — Postgres connection string
|
||||||
- `auth.mode` — `api_keys` or `oauth_client_credentials`
|
- `auth.keys` — static API keys for MCP access via `x-brain-key` or `Authorization: Bearer <key>`
|
||||||
- `auth.keys` — API keys for MCP access via `x-brain-key` or `Authorization: Bearer <key>` when `auth.mode=api_keys`
|
- `auth.oauth.clients` — optional OAuth client credentials registry
|
||||||
- `auth.oauth.clients` — client registry when `auth.mode=oauth_client_credentials`
|
- `ai.providers` — named provider definitions (`litellm`, `ollama`, `openrouter`)
|
||||||
|
- `ai.embeddings.primary` / `ai.metadata.primary` — primary role targets (`provider` + `model`)
|
||||||
|
- `ai.embeddings.fallbacks` / `ai.metadata.fallbacks` — sequential fallback targets
|
||||||
- `mcp.version` is build-generated and should not be set in config
|
- `mcp.version` is build-generated and should not be set in config
|
||||||
|
|
||||||
**OAuth Client Credentials flow** (`auth.mode=oauth_client_credentials`):
|
Config schema is versioned. Current schema version is `2`.
|
||||||
|
|
||||||
|
Use the migration helper to rewrite legacy configs in-place:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/amcs-migrate-config --config ./configs/dev.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--dry-run` to print migrated YAML without writing.
|
||||||
|
Server startup migrates older config formats in memory only and does not write files.
|
||||||
|
|
||||||
|
**OAuth Client Credentials flow**:
|
||||||
|
|
||||||
1. Obtain a token — `POST /oauth/token` (public, no auth required):
|
1. Obtain a token — `POST /oauth/token` (public, no auth required):
|
||||||
```
|
```
|
||||||
@@ -233,10 +288,11 @@ Config is YAML-driven. Copy `configs/config.example.yaml` and set:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, pass `client_id` and `client_secret` as body parameters instead of `Authorization: Basic`. Direct `Authorization: Basic` credential validation on the MCP endpoint is also supported as a fallback (no token required).
|
Alternatively, pass `client_id` and `client_secret` as body parameters instead of `Authorization: Basic`. Direct `Authorization: Basic` credential validation on the MCP endpoint is also supported as a fallback (no token required).
|
||||||
- `ai.litellm.base_url` and `ai.litellm.api_key` — LiteLLM proxy
|
- `AMCS_LITELLM_BASE_URL` / `AMCS_LITELLM_API_KEY` override all configured LiteLLM providers
|
||||||
- `ai.ollama.base_url` and `ai.ollama.api_key` — Ollama local or remote server
|
- `AMCS_OLLAMA_BASE_URL` / `AMCS_OLLAMA_API_KEY` override all configured Ollama providers
|
||||||
|
- `AMCS_OPENROUTER_API_KEY` overrides all configured OpenRouter providers
|
||||||
|
|
||||||
See `llm/plan.md` for full architecture and implementation plan.
|
See `llm/plan.md` for an audited high-level status summary of the original implementation plan, and `llm/todo.md` for the audited backfill/fallback follow-up status.
|
||||||
|
|
||||||
## Backfill
|
## Backfill
|
||||||
|
|
||||||
@@ -499,13 +555,110 @@ Recommended Apache settings:
|
|||||||
- `ProxyTimeout 600` and `ProxyPass ... timeout=600` give Apache enough time to wait for the Go backend.
|
- `ProxyTimeout 600` and `ProxyPass ... timeout=600` give Apache enough time to wait for the Go backend.
|
||||||
- If another proxy or load balancer sits in front of Apache, align its size and timeout settings too.
|
- If another proxy or load balancer sits in front of Apache, align its size and timeout settings too.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
`amcs-cli` is a pre-built CLI client for the AMCS MCP server. Download it from https://git.warky.dev/wdevs/amcs/releases
|
||||||
|
|
||||||
|
The primary purpose is to give agents and MCP clients a ready-made bridge to the AMCS server so they do not need to implement their own HTTP MCP client. Configure it once and any stdio-based MCP client can use AMCS immediately.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `amcs-cli tools` | List all tools available on the remote server |
|
||||||
|
| `amcs-cli call <tool>` | Call a tool by name with `--arg key=value` flags |
|
||||||
|
| `amcs-cli stdio` | Start a stdio MCP bridge backed by the remote server |
|
||||||
|
|
||||||
|
`stdio` is the main integration point. It connects to the remote HTTP MCP server, discovers all its tools, and re-exposes them over stdio. Register it as a stdio MCP server in your agent config and it proxies every tool call through to AMCS.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Config file: `~/.config/amcs/config.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server: https://your-amcs-server
|
||||||
|
token: your-bearer-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Env vars override the config file: `AMCS_SERVER` (preferred), `AMCS_URL` (legacy alias), and `AMCS_TOKEN`. Flags `--server` and `--token` override env vars.
|
||||||
|
|
||||||
|
### stdio MCP client setup
|
||||||
|
|
||||||
|
#### Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add --transport stdio amcs amcs-cli stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
With inline credentials (no config file):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add --transport stdio amcs amcs-cli stdio \
|
||||||
|
--env AMCS_SERVER=https://your-amcs-server \
|
||||||
|
--env AMCS_TOKEN=your-bearer-token
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Output format
|
||||||
|
|
||||||
|
`call` outputs JSON by default. Pass `--output yaml` for YAML.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Run the SQL migrations against a local database with:
|
Run the SQL migrations against a local database with:
|
||||||
|
|
||||||
`DATABASE_URL=postgres://... make migrate`
|
`DATABASE_URL=postgres://... make migrate`
|
||||||
|
|
||||||
LLM integration instructions are served at `/llm`.
|
### Backend + embedded UI build
|
||||||
|
|
||||||
|
The web UI now lives in the top-level `ui/` module and is embedded into the Go binary at build time with `go:embed`.
|
||||||
|
|
||||||
|
### Admin UI deployment model
|
||||||
|
|
||||||
|
AMCS uses a **lightweight embedded SPA panel** model:
|
||||||
|
|
||||||
|
- the Svelte admin app is compiled to static assets
|
||||||
|
- assets are embedded in the server binary and served from `/`
|
||||||
|
- backend APIs (`/api/status`, `/api/rs/*`, admin action routes, OAuth endpoints) stay on the same origin
|
||||||
|
- auth is enforced server-side for all sensitive API routes
|
||||||
|
|
||||||
|
This keeps deployment simple (single binary/container) while preserving SPA ergonomics for operator workflows.
|
||||||
|
|
||||||
|
### UI stack baseline
|
||||||
|
|
||||||
|
The admin frontend baseline is:
|
||||||
|
|
||||||
|
- Svelte 5 for the app shell and pages
|
||||||
|
- ResolveSpec-backed APIs for data access
|
||||||
|
- `@warkypublic/svelix` for admin UX components (including `GridlerFull` and form controllers)
|
||||||
|
- `@warkypublic/artemis-kit` as the default JavaScript tooling dependency baseline in `ui/package.json`
|
||||||
|
|
||||||
|
**Use `pnpm` for all UI work in this repo.**
|
||||||
|
|
||||||
|
- `make build` — runs the real UI build first, then compiles the Go server
|
||||||
|
- `make test` — runs `svelte-check` for the frontend and `go test ./...` for the backend
|
||||||
|
- `make ui-install` — installs frontend dependencies with `pnpm install --frozen-lockfile`
|
||||||
|
- `make ui-build` — builds only the frontend bundle
|
||||||
|
- `make ui-dev` — starts the Vite dev server with hot reload on `http://localhost:5173`
|
||||||
|
- `make ui-check` — runs the frontend type and Svelte checks
|
||||||
|
|
||||||
|
### Local UI workflow
|
||||||
|
|
||||||
|
For the normal production-style local flow:
|
||||||
|
|
||||||
|
1. Start the backend: `./scripts/run-local.sh configs/dev.yaml`
|
||||||
|
2. Open `http://localhost:8080`
|
||||||
|
|
||||||
|
For frontend iteration with hot reload and no Go rebuilds:
|
||||||
|
|
||||||
|
1. Start the backend once: `go run ./cmd/amcs-server --config configs/dev.yaml`
|
||||||
|
2. In another shell start the UI dev server: `make ui-dev`
|
||||||
|
3. Open `http://localhost:5173`
|
||||||
|
|
||||||
|
The Vite dev server proxies backend routes such as `/api/status`, `/llm`, `/healthz`, `/readyz`, `/files`, `/mcp`, and the OAuth endpoints back to the Go server on `http://127.0.0.1:8080` by default. Override that target with `AMCS_UI_BACKEND` if needed.
|
||||||
|
|
||||||
|
The root page (`/`) is now the Svelte frontend. It preserves the existing landing-page content and status information by fetching data from `GET /api/status`.
|
||||||
|
|
||||||
|
LLM integration instructions are still served at `/llm`.
|
||||||
|
|
||||||
## Containers
|
## Containers
|
||||||
|
|
||||||
@@ -530,29 +683,50 @@ Notes:
|
|||||||
- Database migrations `001` through `005` run automatically when the Postgres volume is created for the first time.
|
- Database migrations `001` through `005` run automatically when the Postgres volume is created for the first time.
|
||||||
- `migrations/006_rls_and_grants.sql` is intentionally skipped during container bootstrap because it contains deployment-specific grants for a role named `amcs_user`.
|
- `migrations/006_rls_and_grants.sql` is intentionally skipped during container bootstrap because it contains deployment-specific grants for a role named `amcs_user`.
|
||||||
|
|
||||||
|
### Run config migration with Compose
|
||||||
|
|
||||||
|
The container image now includes `/app/amcs-migrate-config`.
|
||||||
|
|
||||||
|
Dry-run (prints migrated YAML, does not write files):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile tools run --rm migrate-config --config /app/configs/dev.yaml --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply migration in-place (writes file + creates backup):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile tools run --rm migrate-config --config /app/configs/dev.yaml
|
||||||
|
```
|
||||||
|
|
||||||
## Ollama
|
## Ollama
|
||||||
|
|
||||||
Set `ai.provider: "ollama"` to use a local or self-hosted Ollama server through its OpenAI-compatible API.
|
Set your role targets to an Ollama provider to use a local or self-hosted Ollama server through its OpenAI-compatible API.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ai:
|
ai:
|
||||||
provider: "ollama"
|
providers:
|
||||||
|
local:
|
||||||
|
type: "ollama"
|
||||||
|
base_url: "http://localhost:11434/v1"
|
||||||
|
api_key: "ollama"
|
||||||
|
request_headers: {}
|
||||||
embeddings:
|
embeddings:
|
||||||
model: "nomic-embed-text"
|
|
||||||
dimensions: 768
|
dimensions: 768
|
||||||
|
primary:
|
||||||
|
provider: "local"
|
||||||
|
model: "nomic-embed-text"
|
||||||
metadata:
|
metadata:
|
||||||
model: "llama3.2"
|
|
||||||
temperature: 0.1
|
temperature: 0.1
|
||||||
ollama:
|
primary:
|
||||||
base_url: "http://localhost:11434/v1"
|
provider: "local"
|
||||||
api_key: "ollama"
|
model: "llama3.2"
|
||||||
request_headers: {}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- For remote Ollama servers, point `ai.ollama.base_url` at the remote `/v1` endpoint.
|
- For remote Ollama servers, point `ai.providers.<name>.base_url` at the remote `/v1` endpoint.
|
||||||
- The client always sends Bearer auth; Ollama ignores it locally, so `api_key: "ollama"` is a safe default.
|
- The client always sends Bearer auth; Ollama ignores it locally, so `api_key: "ollama"` is a safe default.
|
||||||
- `ai.embeddings.dimensions` must match the embedding model you actually use, or startup will fail the database vector-dimension check.
|
- `ai.embeddings.dimensions` must match the embedding model you actually use, or startup will fail the database vector-dimension check.
|
||||||
|
|||||||
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
90
changelog.md
Normal file
90
changelog.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-21
|
||||||
|
|
||||||
|
### 2026-04-21 21h - Config Schema v2 Introduced
|
||||||
|
|
||||||
|
- Refactored configuration to schema version `2` with named AI providers and role-based model chains.
|
||||||
|
- Added support for per-role primary and fallback targets for embeddings and metadata.
|
||||||
|
- Added optional background role overrides for backfill and metadata retry workers.
|
||||||
|
|
||||||
|
### 2026-04-21 21h - Automatic v1 -> v2 Migration
|
||||||
|
|
||||||
|
- Added config migration framework with explicit schema versioning.
|
||||||
|
- Implemented `v1 -> v2` migration to transform legacy provider blocks into named providers + role chains.
|
||||||
|
- Loader now auto-migrates older config files, rewrites migrated YAML, and creates timestamped backups.
|
||||||
|
|
||||||
|
### 2026-04-21 21h - AI Registry and Role Runners
|
||||||
|
|
||||||
|
- Added `ai.Registry` to build provider clients from named provider config entries.
|
||||||
|
- Added `EmbeddingRunner` and `MetadataRunner` with sequential fallback execution.
|
||||||
|
- Added target health tracking with cooldowns for transient/permanent/empty-response failures.
|
||||||
|
|
||||||
|
### 2026-04-21 21h - App and Tool Wiring Updates
|
||||||
|
|
||||||
|
- Rewired app startup to use provider registry + role runners for foreground and background flows.
|
||||||
|
- Updated capture, search, summarize, context, recall, backfill, metadata retry, and reparse paths to use new runners.
|
||||||
|
- Preserved environment override behavior for provider credentials/endpoints across matching provider types.
|
||||||
|
|
||||||
|
### 2026-04-21 21h - Migrate Config CLI Added
|
||||||
|
|
||||||
|
- Added `cmd/amcs-migrate-config` CLI to migrate config files to the current schema version.
|
||||||
|
- Supports dry-run output and in-place write mode with automatic backup file creation.
|
||||||
|
|
||||||
|
### 2026-04-21 21h - Tests and Documentation Updated
|
||||||
|
|
||||||
|
- Added focused tests for config migration, AI registry behavior, and runner fallback behavior.
|
||||||
|
- Updated `configs/config.example.yaml` to the new v2 schema.
|
||||||
|
- Updated README configuration sections and migration guidance to reflect v2 and `amcs-migrate-config` usage.
|
||||||
|
|
||||||
|
### 2026-04-21 21h - Uncommitted File Change List
|
||||||
|
|
||||||
|
- Modified: `.gitignore`
|
||||||
|
- Modified: `README.md`
|
||||||
|
- Modified: `configs/config.example.yaml`
|
||||||
|
- Modified: `internal/ai/compat/client.go`
|
||||||
|
- Modified: `internal/ai/compat/client_test.go`
|
||||||
|
- Modified: `internal/app/app.go`
|
||||||
|
- Modified: `internal/config/config.go`
|
||||||
|
- Modified: `internal/config/loader.go`
|
||||||
|
- Modified: `internal/config/loader_test.go`
|
||||||
|
- Modified: `internal/config/validate.go`
|
||||||
|
- Modified: `internal/config/validate_test.go`
|
||||||
|
- Modified: `internal/mcpserver/server.go`
|
||||||
|
- Modified: `internal/mcpserver/streamable_integration_test.go`
|
||||||
|
- Modified: `internal/tools/backfill.go`
|
||||||
|
- Modified: `internal/tools/capture.go`
|
||||||
|
- Modified: `internal/tools/context.go`
|
||||||
|
- Modified: `internal/tools/enrichment_retry.go`
|
||||||
|
- Modified: `internal/tools/links.go`
|
||||||
|
- Modified: `internal/tools/metadata_retry.go`
|
||||||
|
- Modified: `internal/tools/recall.go`
|
||||||
|
- Modified: `internal/tools/reparse_metadata.go`
|
||||||
|
- Modified: `internal/tools/retrieval.go`
|
||||||
|
- Modified: `internal/tools/search.go`
|
||||||
|
- Modified: `internal/tools/summarize.go`
|
||||||
|
- Modified: `internal/tools/update.go`
|
||||||
|
- Deleted: `internal/ai/factory.go`
|
||||||
|
- Deleted: `internal/ai/factory_test.go`
|
||||||
|
- Deleted: `internal/ai/litellm/client.go`
|
||||||
|
- Deleted: `internal/ai/ollama/client.go`
|
||||||
|
- Deleted: `internal/ai/openrouter/client.go`
|
||||||
|
- Deleted: `internal/ai/provider.go`
|
||||||
|
- New: `changelog.md`
|
||||||
|
- New: `cmd/amcs-migrate-config/main.go`
|
||||||
|
- New: `internal/ai/registry.go`
|
||||||
|
- New: `internal/ai/registry_test.go`
|
||||||
|
- New: `internal/ai/runner.go`
|
||||||
|
- New: `internal/ai/runner_test.go`
|
||||||
|
- New: `internal/config/migrate.go`
|
||||||
|
- New: `internal/config/migrate_test.go`
|
||||||
|
|
||||||
|
### 2026-04-21 21h - Docker Support for Config Migration CLI
|
||||||
|
- Added `amcs-migrate-config` binary to the Docker image build output.
|
||||||
|
- Added `migrate-config` service in `docker-compose.yml` under the `tools` profile.
|
||||||
|
- Documented compose-based migration commands (dry-run and in-place apply) in the README.
|
||||||
|
|
||||||
|
### 2026-04-21 21h - Startup Migration Write Disabled
|
||||||
|
- Changed config loading to migrate legacy schemas in memory only during startup.
|
||||||
|
- Removed automatic file rewrite and backup creation from the startup config loader.
|
||||||
|
- Added loader log hint to use `amcs-migrate-config` when persistent conversion is needed.
|
||||||
98
cmd/amcs-cli/cmd/call.go
Normal file
98
cmd/amcs-cli/cmd/call.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var argFlags []string
|
||||||
|
|
||||||
|
var callCmd = &cobra.Command{
|
||||||
|
Use: "call <tool>",
|
||||||
|
Short: "Call a remote AMCS tool",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
toolName := args[0]
|
||||||
|
toolArgs, err := parseArgs(argFlags)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := connectRemote(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = session.Close() }()
|
||||||
|
|
||||||
|
res, err := session.CallTool(cmd.Context(), &mcp.CallToolParams{Name: toolName, Arguments: toolArgs})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("call tool %q: %w", toolName, err)
|
||||||
|
}
|
||||||
|
return printOutput(res)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
callCmd.Flags().StringArrayVar(&argFlags, "arg", nil, "Tool argument in key=value format (repeatable)")
|
||||||
|
rootCmd.AddCommand(callCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArgs(items []string) (map[string]any, error) {
|
||||||
|
result := make(map[string]any, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
key, value, ok := strings.Cut(item, "=")
|
||||||
|
if !ok || strings.TrimSpace(key) == "" {
|
||||||
|
return nil, fmt.Errorf("invalid --arg %q: want key=value", item)
|
||||||
|
}
|
||||||
|
result[key] = parseScalar(value)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScalar(s string) any {
|
||||||
|
if s == "true" || s == "false" {
|
||||||
|
b, _ := strconv.ParseBool(s)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(s, 64); err == nil && strings.ContainsAny(s, ".eE") {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal([]byte(s), &v); err == nil {
|
||||||
|
switch v.(type) {
|
||||||
|
case map[string]any, []any, float64, bool, nil:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func printOutput(v any) error {
|
||||||
|
switch outputFlag {
|
||||||
|
case "yaml":
|
||||||
|
data, err := yaml.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal yaml: %w", err)
|
||||||
|
}
|
||||||
|
_, err = os.Stdout.Write(data)
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal json: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
_, err = os.Stdout.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
60
cmd/amcs-cli/cmd/config.go
Normal file
60
cmd/amcs-cli/cmd/config.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server string `yaml:"server"`
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfigPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve home dir: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "amcs", "config.yaml"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConfigPath(path string) (string, error) {
|
||||||
|
if strings.TrimSpace(path) != "" {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
return defaultConfigPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfigFile(path string) (Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return cfg, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return cfg, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveConfigFile(path string, cfg Config) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal config: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
151
cmd/amcs-cli/cmd/root.go
Normal file
151
cmd/amcs-cli/cmd/root.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfgFile string
|
||||||
|
serverFlag string
|
||||||
|
tokenFlag string
|
||||||
|
outputFlag string
|
||||||
|
verbose bool
|
||||||
|
cfg Config
|
||||||
|
)
|
||||||
|
|
||||||
|
const cliUserAgent = "amcs-cli/0.0.1"
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "amcs-cli",
|
||||||
|
Short: "CLI for connecting to a remote AMCS MCP server",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return loadConfig()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Path to config file")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&serverFlag, "server", "", "AMCS server URL")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "AMCS bearer token")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&outputFlag, "output", "json", "Output format: json or yaml")
|
||||||
|
rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Enable verbose logging to stderr")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() error {
|
||||||
|
path, err := resolveConfigPath(cfgFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
loaded, err := loadConfigFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg = loaded
|
||||||
|
if v := strings.TrimSpace(os.Getenv("AMCS_SERVER")); v != "" {
|
||||||
|
cfg.Server = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("AMCS_URL")); v != "" {
|
||||||
|
cfg.Server = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("AMCS_TOKEN")); v != "" {
|
||||||
|
cfg.Token = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(serverFlag); v != "" {
|
||||||
|
cfg.Server = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(tokenFlag); v != "" {
|
||||||
|
cfg.Token = v
|
||||||
|
}
|
||||||
|
outputFlag = strings.ToLower(strings.TrimSpace(outputFlag))
|
||||||
|
if outputFlag != "json" && outputFlag != "yaml" {
|
||||||
|
return fmt.Errorf("invalid --output %q: must be json or yaml", outputFlag)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireServer() error {
|
||||||
|
if strings.TrimSpace(cfg.Server) == "" {
|
||||||
|
return fmt.Errorf("server URL is required; set --server, AMCS_SERVER, AMCS_URL, or config server")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func endpointURL() string {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(cfg.Server), "/")
|
||||||
|
if strings.HasSuffix(base, "/mcp") {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + "/mcp"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 0,
|
||||||
|
Transport: &bearerTransport{
|
||||||
|
base: http.DefaultTransport,
|
||||||
|
token: cfg.Token,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type bearerTransport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
base := t.base
|
||||||
|
if base == nil {
|
||||||
|
base = http.DefaultTransport
|
||||||
|
}
|
||||||
|
clone := req.Clone(req.Context())
|
||||||
|
if strings.TrimSpace(clone.Header.Get("User-Agent")) == "" {
|
||||||
|
clone.Header.Set("User-Agent", cliUserAgent)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(t.token) != "" {
|
||||||
|
clone.Header.Set("Authorization", "Bearer "+t.token)
|
||||||
|
}
|
||||||
|
return base.RoundTrip(clone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectRemote(ctx context.Context) (*mcp.ClientSession, error) {
|
||||||
|
if err := requireServer(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
verboseLogf("connecting to %s", endpointURL())
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "amcs-cli", Version: "0.0.1"}, nil)
|
||||||
|
transport := &mcp.StreamableClientTransport{
|
||||||
|
Endpoint: endpointURL(),
|
||||||
|
HTTPClient: newHTTPClient(),
|
||||||
|
DisableStandaloneSSE: true,
|
||||||
|
}
|
||||||
|
session, err := client.Connect(ctx, transport, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to AMCS server: %w", err)
|
||||||
|
}
|
||||||
|
verboseLogf("connected to %s", endpointURL())
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verboseLogf(format string, args ...any) {
|
||||||
|
if !verbose {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "[amcs-cli] "+format+"\n", args...)
|
||||||
|
}
|
||||||
35
cmd/amcs-cli/cmd/root_test.go
Normal file
35
cmd/amcs-cli/cmd/root_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBearerTransportFormatsBearerToken(t *testing.T) {
|
||||||
|
const want = "Bearer X"
|
||||||
|
const wantUA = "amcs-cli/0.0.1"
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != want {
|
||||||
|
t.Fatalf("Authorization header = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("User-Agent"); got != wantUA {
|
||||||
|
t.Fatalf("User-Agent header = %q, want %q", got, wantUA)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client := &http.Client{Transport: &bearerTransport{token: "X"}}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client.Do() error = %v", err)
|
||||||
|
}
|
||||||
|
_ = res.Body.Close()
|
||||||
|
}
|
||||||
89
cmd/amcs-cli/cmd/sse.go
Normal file
89
cmd/amcs-cli/cmd/sse.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sseCmd = &cobra.Command{
|
||||||
|
Use: "sse",
|
||||||
|
Short: "Run a stdio MCP bridge backed by a remote AMCS server using SSE transport (widely supported by hosted MCP clients)",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
ctx := cmd.Context()
|
||||||
|
|
||||||
|
if err := requireServer(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "amcs-cli", Version: "0.0.1"}, nil)
|
||||||
|
transport := &mcp.SSEClientTransport{
|
||||||
|
Endpoint: sseEndpointURL(),
|
||||||
|
HTTPClient: newHTTPClient(),
|
||||||
|
}
|
||||||
|
|
||||||
|
verboseLogf("connecting to SSE endpoint %s", sseEndpointURL())
|
||||||
|
remote, err := client.Connect(ctx, transport, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to AMCS SSE endpoint: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = remote.Close() }()
|
||||||
|
verboseLogf("connected to SSE endpoint %s", sseEndpointURL())
|
||||||
|
|
||||||
|
tools, err := remote.ListTools(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load remote tools: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := mcp.NewServer(&mcp.Implementation{
|
||||||
|
Name: "amcs-cli",
|
||||||
|
Title: "AMCS CLI Bridge (SSE)",
|
||||||
|
Version: "0.0.1",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
for _, tool := range tools.Tools {
|
||||||
|
remoteTool := tool
|
||||||
|
server.AddTool(&mcp.Tool{
|
||||||
|
Name: remoteTool.Name,
|
||||||
|
Description: remoteTool.Description,
|
||||||
|
InputSchema: remoteTool.InputSchema,
|
||||||
|
OutputSchema: remoteTool.OutputSchema,
|
||||||
|
Annotations: remoteTool.Annotations,
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return remote.CallTool(ctx, &mcp.CallToolParams{
|
||||||
|
Name: req.Params.Name,
|
||||||
|
Arguments: req.Params.Arguments,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := server.Connect(ctx, &mcp.StdioTransport{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("start stdio bridge: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = session.Close() }()
|
||||||
|
verboseLogf("sse stdio bridge ready")
|
||||||
|
verboseLogf("waiting for MCP commands on stdin")
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func sseEndpointURL() string {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(cfg.Server), "/")
|
||||||
|
if strings.HasSuffix(base, "/mcp") {
|
||||||
|
base = strings.TrimSuffix(base, "/mcp")
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(base, "/sse") {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + "/sse"
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(sseCmd)
|
||||||
|
}
|
||||||
64
cmd/amcs-cli/cmd/stdio.go
Normal file
64
cmd/amcs-cli/cmd/stdio.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var stdioCmd = &cobra.Command{
|
||||||
|
Use: "stdio",
|
||||||
|
Short: "Run a stdio MCP bridge backed by a remote AMCS server",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
ctx := cmd.Context()
|
||||||
|
remote, err := connectRemote(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = remote.Close() }()
|
||||||
|
|
||||||
|
tools, err := remote.ListTools(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load remote tools: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := mcp.NewServer(&mcp.Implementation{
|
||||||
|
Name: "amcs-cli",
|
||||||
|
Title: "AMCS CLI Bridge",
|
||||||
|
Version: "0.0.1",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
for _, tool := range tools.Tools {
|
||||||
|
remoteTool := tool
|
||||||
|
server.AddTool(&mcp.Tool{
|
||||||
|
Name: remoteTool.Name,
|
||||||
|
Description: remoteTool.Description,
|
||||||
|
InputSchema: remoteTool.InputSchema,
|
||||||
|
OutputSchema: remoteTool.OutputSchema,
|
||||||
|
Annotations: remoteTool.Annotations,
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return remote.CallTool(ctx, &mcp.CallToolParams{
|
||||||
|
Name: req.Params.Name,
|
||||||
|
Arguments: req.Params.Arguments,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := server.Connect(ctx, &mcp.StdioTransport{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("start stdio bridge: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = session.Close() }()
|
||||||
|
verboseLogf("stdio bridge connected to remote AMCS and ready")
|
||||||
|
verboseLogf("waiting for MCP commands on stdin")
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(stdioCmd)
|
||||||
|
}
|
||||||
38
cmd/amcs-cli/cmd/tools.go
Normal file
38
cmd/amcs-cli/cmd/tools.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var toolsCmd = &cobra.Command{
|
||||||
|
Use: "tools",
|
||||||
|
Short: "List tools available on the remote AMCS server",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
session, err := connectRemote(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = session.Close() }()
|
||||||
|
|
||||||
|
res, err := session.ListTools(cmd.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list tools: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "NAME\tDESCRIPTION")
|
||||||
|
for _, tool := range res.Tools {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\n", tool.Name, strings.TrimSpace(tool.Description))
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(toolsCmd)
|
||||||
|
}
|
||||||
7
cmd/amcs-cli/main.go
Normal file
7
cmd/amcs-cli/main.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "git.warky.dev/wdevs/amcs/cmd/amcs-cli/cmd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
||||||
105
cmd/amcs-migrate-config/main.go
Normal file
105
cmd/amcs-migrate-config/main.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
configPath string
|
||||||
|
dryRun bool
|
||||||
|
toVersion int
|
||||||
|
)
|
||||||
|
flag.StringVar(&configPath, "config", "", "Path to the YAML config file (default: $AMCS_CONFIG or ./configs/dev.yaml)")
|
||||||
|
flag.BoolVar(&dryRun, "dry-run", false, "Print the migrated config to stdout instead of writing it back")
|
||||||
|
flag.IntVar(&toVersion, "to-version", config.CurrentConfigVersion, "Stop migrating after reaching this version")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if toVersion <= 0 || toVersion > config.CurrentConfigVersion {
|
||||||
|
log.Fatalf("invalid -to-version %d (must be between 1 and %d)", toVersion, config.CurrentConfigVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := config.ResolvePath(configPath)
|
||||||
|
original, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read config %q: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := map[string]any{}
|
||||||
|
if err := yaml.Unmarshal(original, &raw); err != nil {
|
||||||
|
log.Fatalf("decode config %q: %v", path, err)
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
raw = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
applied, err := migrateUpTo(raw, toVersion)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(applied) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s already at version %d; nothing to do\n", path, currentVersion(raw))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("marshal migrated config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, step := range applied {
|
||||||
|
fmt.Fprintf(os.Stderr, "applied migration v%d -> v%d: %s\n", step.From, step.To, step.Describe)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
_, _ = os.Stdout.Write(out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backup := fmt.Sprintf("%s.bak.%d", path, time.Now().Unix())
|
||||||
|
if err := os.WriteFile(backup, original, 0o600); err != nil {
|
||||||
|
log.Fatalf("write backup %q: %v", backup, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, out, 0o600); err != nil {
|
||||||
|
log.Fatalf("write migrated config %q: %v", path, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "wrote migrated config to %s (backup: %s)\n", path, backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateUpTo runs the migration ladder but stops at the requested version.
|
||||||
|
func migrateUpTo(raw map[string]any, target int) ([]config.ConfigMigration, error) {
|
||||||
|
if currentVersion(raw) >= target {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if target == config.CurrentConfigVersion {
|
||||||
|
return config.Migrate(raw)
|
||||||
|
}
|
||||||
|
// Partial migrations are rare; for now reject anything other than the
|
||||||
|
// current version target since the migration ladder is short.
|
||||||
|
return nil, fmt.Errorf("partial migration to v%d is not supported (use -to-version=%d)", target, config.CurrentConfigVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentVersion(raw map[string]any) int {
|
||||||
|
v, ok := raw["version"]
|
||||||
|
if !ok {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
case int64:
|
||||||
|
return int(n)
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
server:
|
server:
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 8080
|
port: 8080
|
||||||
@@ -9,6 +11,7 @@ server:
|
|||||||
|
|
||||||
mcp:
|
mcp:
|
||||||
path: "/mcp"
|
path: "/mcp"
|
||||||
|
sse_path: "/sse"
|
||||||
server_name: "amcs"
|
server_name: "amcs"
|
||||||
transport: "streamable_http"
|
transport: "streamable_http"
|
||||||
session_timeout: "10m"
|
session_timeout: "10m"
|
||||||
@@ -26,7 +29,7 @@ auth:
|
|||||||
- id: "oauth-client"
|
- id: "oauth-client"
|
||||||
client_id: ""
|
client_id: ""
|
||||||
client_secret: ""
|
client_secret: ""
|
||||||
description: "used when auth.mode=oauth_client_credentials"
|
description: "optional OAuth client credentials"
|
||||||
|
|
||||||
database:
|
database:
|
||||||
url: "postgres://postgres:postgres@localhost:5432/amcs?sslmode=disable"
|
url: "postgres://postgres:postgres@localhost:5432/amcs?sslmode=disable"
|
||||||
@@ -36,33 +39,58 @@ database:
|
|||||||
max_conn_idle_time: "10m"
|
max_conn_idle_time: "10m"
|
||||||
|
|
||||||
ai:
|
ai:
|
||||||
provider: "litellm"
|
providers:
|
||||||
|
default:
|
||||||
|
type: "litellm"
|
||||||
|
base_url: "http://localhost:4000/v1"
|
||||||
|
api_key: "replace-me"
|
||||||
|
request_headers: {}
|
||||||
|
|
||||||
|
ollama_local:
|
||||||
|
type: "ollama"
|
||||||
|
base_url: "http://localhost:11434/v1"
|
||||||
|
api_key: "ollama"
|
||||||
|
request_headers: {}
|
||||||
|
|
||||||
|
openrouter:
|
||||||
|
type: "openrouter"
|
||||||
|
base_url: "https://openrouter.ai/api/v1"
|
||||||
|
api_key: "replace-me"
|
||||||
|
app_name: "amcs"
|
||||||
|
site_url: ""
|
||||||
|
request_headers: {}
|
||||||
|
|
||||||
embeddings:
|
embeddings:
|
||||||
model: "openai/text-embedding-3-small"
|
|
||||||
dimensions: 1536
|
dimensions: 1536
|
||||||
|
primary:
|
||||||
|
provider: "default"
|
||||||
|
model: "openai/text-embedding-3-small"
|
||||||
|
fallbacks:
|
||||||
|
- provider: "ollama_local"
|
||||||
|
model: "nomic-embed-text"
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
model: "gpt-4o-mini"
|
|
||||||
fallback_models: []
|
|
||||||
temperature: 0.1
|
temperature: 0.1
|
||||||
log_conversations: false
|
log_conversations: false
|
||||||
litellm:
|
timeout: "10s"
|
||||||
base_url: "http://localhost:4000/v1"
|
primary:
|
||||||
api_key: "replace-me"
|
provider: "default"
|
||||||
use_responses_api: false
|
model: "gpt-4o-mini"
|
||||||
request_headers: {}
|
fallbacks:
|
||||||
embedding_model: "openrouter/openai/text-embedding-3-small"
|
- provider: "openrouter"
|
||||||
metadata_model: "gpt-4o-mini"
|
model: "openai/gpt-4.1-mini"
|
||||||
fallback_metadata_models: []
|
|
||||||
ollama:
|
# Optional overrides for background jobs (backfill_embeddings,
|
||||||
base_url: "http://localhost:11434/v1"
|
# retry_failed_metadata, reparse_thought_metadata).
|
||||||
api_key: "ollama"
|
background:
|
||||||
request_headers: {}
|
embeddings:
|
||||||
openrouter:
|
primary:
|
||||||
base_url: "https://openrouter.ai/api/v1"
|
provider: "default"
|
||||||
api_key: ""
|
model: "openai/text-embedding-3-small"
|
||||||
app_name: "amcs"
|
metadata:
|
||||||
site_url: ""
|
primary:
|
||||||
extra_headers: {}
|
provider: "default"
|
||||||
|
model: "gpt-4o-mini"
|
||||||
|
|
||||||
capture:
|
capture:
|
||||||
source: "mcp"
|
source: "mcp"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ server:
|
|||||||
|
|
||||||
mcp:
|
mcp:
|
||||||
path: "/mcp"
|
path: "/mcp"
|
||||||
|
sse_path: "/sse"
|
||||||
server_name: "amcs"
|
server_name: "amcs"
|
||||||
transport: "streamable_http"
|
transport: "streamable_http"
|
||||||
session_timeout: "10m"
|
session_timeout: "10m"
|
||||||
@@ -24,8 +25,8 @@ auth:
|
|||||||
oauth:
|
oauth:
|
||||||
clients:
|
clients:
|
||||||
- id: "oauth-client"
|
- id: "oauth-client"
|
||||||
client_id: ""
|
client_id: "test_aab32200464910ab697efbd760e7ed2c"
|
||||||
client_secret: ""
|
client_secret: "test_135369559a422b4b93fcb534a4aed2c9"
|
||||||
description: "used when auth.mode=oauth_client_credentials"
|
description: "used when auth.mode=oauth_client_credentials"
|
||||||
|
|
||||||
database:
|
database:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ server:
|
|||||||
|
|
||||||
mcp:
|
mcp:
|
||||||
path: "/mcp"
|
path: "/mcp"
|
||||||
|
sse_path: "/sse"
|
||||||
server_name: "amcs"
|
server_name: "amcs"
|
||||||
transport: "streamable_http"
|
transport: "streamable_http"
|
||||||
session_timeout: "10m"
|
session_timeout: "10m"
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
|
migrate-config:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
profiles: ["tools"]
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- ./configs:/app/configs
|
||||||
|
environment:
|
||||||
|
AMCS_CONFIG: /app/configs/docker.yaml
|
||||||
|
entrypoint: ["/app/amcs-migrate-config"]
|
||||||
|
command: ["--config", "/app/configs/docker.yaml", "--dry-run"]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|
||||||
|
|||||||
65
go.mod
65
go.mod
@@ -3,26 +3,85 @@ module git.warky.dev/wdevs/amcs
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/bitechdev/ResolveSpec v1.0.86
|
||||||
github.com/google/jsonschema-go v0.4.2
|
github.com/google/jsonschema-go v0.4.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.1
|
github.com/jackc/pgx/v5 v5.9.1
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||||
github.com/pgvector/pgvector-go v0.3.0
|
github.com/pgvector/pgvector-go v0.3.0
|
||||||
golang.org/x/sync v0.17.0
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/uptrace/bun v1.2.16
|
||||||
|
github.com/uptrace/bun/dialect/pgdialect v1.2.16
|
||||||
|
github.com/uptrace/bun/driver/pgdriver v1.1.12
|
||||||
|
github.com/uptrace/bunrouter v1.0.23
|
||||||
|
golang.org/x/sync v0.19.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/getsentry/sentry-go v0.40.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // 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/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
||||||
|
github.com/microsoft/go-mssqldb v1.9.5 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.67.4 // indirect
|
||||||
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||||
|
github.com/redis/go-redis/v9 v9.17.2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/segmentio/asm v1.1.3 // indirect
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/spf13/viper v1.21.0 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
|
github.com/tidwall/match v1.2.0 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||||
|
github.com/uptrace/bun/dialect/mssqldialect v1.2.16 // indirect
|
||||||
|
github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 // indirect
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.uber.org/zap v1.27.1 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
gorm.io/driver/postgres v1.6.0 // indirect
|
||||||
|
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||||
|
gorm.io/driver/sqlserver v1.6.3 // indirect
|
||||||
|
gorm.io/gorm v1.31.1 // indirect
|
||||||
|
mellium.im/sasl v0.3.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
422
go.sum
422
go.sum
@@ -1,21 +1,129 @@
|
|||||||
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bitechdev/ResolveSpec v1.0.86 h1:a4yFMMDizrmvDOV61cj/+kD+mEtKL/5EIHY2GcP3uJU=
|
||||||
|
github.com/bitechdev/ResolveSpec v1.0.86/go.mod h1:YZOY2YCD0Kmb+pjAMhOqPh4q82Hij57F/CLlCMkzT78=
|
||||||
|
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
|
||||||
|
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
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 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||||
|
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||||
|
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
|
||||||
|
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||||
|
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo=
|
||||||
|
github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||||
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=
|
github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=
|
||||||
github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=
|
github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=
|
||||||
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
|
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
|
||||||
github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
|
github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
|
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
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=
|
||||||
@@ -24,47 +132,181 @@ github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
|||||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.9.1/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/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||||
|
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||||
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
|
||||||
|
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
|
||||||
|
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||||
|
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||||
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
|
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||||
|
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
||||||
|
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||||
|
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc=
|
github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc=
|
||||||
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
|
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
|
||||||
|
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||||
|
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||||
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||||
|
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||||
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
||||||
|
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
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.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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||||
|
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
|
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||||
github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ=
|
github.com/uptrace/bun v1.2.16 h1:QlObi6ZIK5Ao7kAALnh91HWYNZUBbVwye52fmlQM9kc=
|
||||||
github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0=
|
github.com/uptrace/bun v1.2.16/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM=
|
||||||
github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk=
|
github.com/uptrace/bun/dialect/mssqldialect v1.2.16 h1:rKv0cKPNBviXadB/+2Y/UedA/c1JnwGzUWZkdN5FdSQ=
|
||||||
github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc=
|
github.com/uptrace/bun/dialect/mssqldialect v1.2.16/go.mod h1:J5U7tGKWDsx2Q7MwDZF2417jCdpD6yD/ZMFJcCR80bk=
|
||||||
|
github.com/uptrace/bun/dialect/pgdialect v1.2.16 h1:KFNZ0LxAyczKNfK/IJWMyaleO6eI9/Z5tUv3DE1NVL4=
|
||||||
|
github.com/uptrace/bun/dialect/pgdialect v1.2.16/go.mod h1:IJdMeV4sLfh0LDUZl7TIxLI0LipF1vwTK3hBC7p5qLo=
|
||||||
|
github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 h1:6wVAiYLj1pMibRthGwy4wDLa3D5AQo32Y8rvwPd8CQ0=
|
||||||
|
github.com/uptrace/bun/dialect/sqlitedialect v1.2.16/go.mod h1:Z7+5qK8CGZkDQiPMu+LSdVuDuR1I5jcwtkB1Pi3F82E=
|
||||||
github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w=
|
github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w=
|
||||||
github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM=
|
github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM=
|
||||||
|
github.com/uptrace/bun/driver/sqliteshim v1.2.16 h1:M6Dh5kkDWFbUWBrOsIE1g1zdZ5JbSytTD4piFRBOUAI=
|
||||||
|
github.com/uptrace/bun/driver/sqliteshim v1.2.16/go.mod h1:iKdJ06P3XS+pwKcONjSIK07bbhksH3lWsw3mpfr0+bY=
|
||||||
|
github.com/uptrace/bunrouter v1.0.23 h1:Bi7NKw3uCQkcA/GUCtDNPq5LE5UdR9pe+UyWbjHB/wU=
|
||||||
|
github.com/uptrace/bunrouter v1.0.23/go.mod h1:O3jAcl+5qgnF+ejhgkmbceEk0E/mqaK+ADOocdNpY8M=
|
||||||
github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=
|
github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=
|
||||||
github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=
|
github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=
|
||||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||||
github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc=
|
github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc=
|
||||||
github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
|
github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
@@ -73,27 +315,171 @@ 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/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||||
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
|
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||||
|
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
|
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||||
|
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
|
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
||||||
|
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||||
|
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||||
|
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
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/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
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=
|
||||||
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
|
||||||
|
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
||||||
|
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo=
|
mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo=
|
||||||
mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw=
|
mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw=
|
||||||
|
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
|
||||||
|
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
|
||||||
|
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
@@ -36,36 +35,39 @@ Rules:
|
|||||||
- If unsure, prefer "observation".
|
- If unsure, prefer "observation".
|
||||||
- Do not include any text outside the JSON object.`
|
- Do not include any text outside the JSON object.`
|
||||||
|
|
||||||
|
// Client is a low-level OpenAI-compatible HTTP client. It knows nothing about
|
||||||
|
// role chains, fallbacks, or health — those concerns belong to ai.Runner. Each
|
||||||
|
// method takes the model name per-call so a single Client instance can service
|
||||||
|
// many different models on the same base URL.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
name string
|
name string
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
embeddingModel string
|
headers map[string]string
|
||||||
metadataModel string
|
httpClient *http.Client
|
||||||
fallbackMetadataModels []string
|
log *slog.Logger
|
||||||
temperature float64
|
|
||||||
headers map[string]string
|
|
||||||
httpClient *http.Client
|
|
||||||
log *slog.Logger
|
|
||||||
dimensions int
|
|
||||||
logConversations bool
|
|
||||||
modelHealthMu sync.Mutex
|
|
||||||
modelHealth map[string]modelHealthState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Name string
|
Name string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
APIKey string
|
APIKey string
|
||||||
EmbeddingModel string
|
Headers map[string]string
|
||||||
MetadataModel string
|
HTTPClient *http.Client
|
||||||
FallbackMetadataModels []string
|
Log *slog.Logger
|
||||||
Temperature float64
|
}
|
||||||
Headers map[string]string
|
|
||||||
HTTPClient *http.Client
|
// MetadataOptions control a single ExtractMetadataWith call.
|
||||||
Log *slog.Logger
|
type MetadataOptions struct {
|
||||||
Dimensions int
|
Model string
|
||||||
LogConversations bool
|
Temperature float64
|
||||||
|
LogConversations bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummarizeOptions control a single SummarizeWith call.
|
||||||
|
type SummarizeOptions struct {
|
||||||
|
Model string
|
||||||
|
Temperature float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type embeddingsRequest struct {
|
type embeddingsRequest struct {
|
||||||
@@ -127,65 +129,38 @@ type providerError struct {
|
|||||||
|
|
||||||
const maxMetadataAttempts = 3
|
const maxMetadataAttempts = 3
|
||||||
|
|
||||||
const (
|
// ErrEmptyResponse and ErrNoJSONObject are sentinel errors callers can inspect
|
||||||
emptyResponseCircuitThreshold = 3
|
// to classify metadata failures (e.g. bump empty-response health counters).
|
||||||
emptyResponseCircuitTTL = 5 * time.Minute
|
|
||||||
permanentModelFailureTTL = 24 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errMetadataEmptyResponse = errors.New("metadata empty response")
|
ErrEmptyResponse = errors.New("metadata empty response")
|
||||||
errMetadataNoJSONObject = errors.New("metadata response contains no JSON object")
|
ErrNoJSONObject = errors.New("metadata response contains no JSON object")
|
||||||
)
|
)
|
||||||
|
|
||||||
type modelHealthState struct {
|
|
||||||
consecutiveEmpty int
|
|
||||||
unhealthyUntil time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(cfg Config) *Client {
|
func New(cfg Config) *Client {
|
||||||
fallbacks := make([]string, 0, len(cfg.FallbackMetadataModels))
|
|
||||||
seen := make(map[string]struct{}, len(cfg.FallbackMetadataModels))
|
|
||||||
for _, model := range cfg.FallbackMetadataModels {
|
|
||||||
model = strings.TrimSpace(model)
|
|
||||||
if model == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[model]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[model] = struct{}{}
|
|
||||||
fallbacks = append(fallbacks, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
name: cfg.Name,
|
name: cfg.Name,
|
||||||
baseURL: cfg.BaseURL,
|
baseURL: cfg.BaseURL,
|
||||||
apiKey: cfg.APIKey,
|
apiKey: cfg.APIKey,
|
||||||
embeddingModel: cfg.EmbeddingModel,
|
headers: cfg.Headers,
|
||||||
metadataModel: cfg.MetadataModel,
|
httpClient: cfg.HTTPClient,
|
||||||
fallbackMetadataModels: fallbacks,
|
log: cfg.Log,
|
||||||
temperature: cfg.Temperature,
|
|
||||||
headers: cfg.Headers,
|
|
||||||
httpClient: cfg.HTTPClient,
|
|
||||||
log: cfg.Log,
|
|
||||||
dimensions: cfg.Dimensions,
|
|
||||||
logConversations: cfg.LogConversations,
|
|
||||||
modelHealth: make(map[string]modelHealthState),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Embed(ctx context.Context, input string) ([]float32, error) {
|
func (c *Client) Name() string { return c.name }
|
||||||
|
|
||||||
|
// EmbedWith generates an embedding for the given input using model.
|
||||||
|
func (c *Client) EmbedWith(ctx context.Context, model, input string) ([]float32, error) {
|
||||||
input = strings.TrimSpace(input)
|
input = strings.TrimSpace(input)
|
||||||
if input == "" {
|
if input == "" {
|
||||||
return nil, fmt.Errorf("%s embed: input must not be empty", c.name)
|
return nil, fmt.Errorf("%s embed: input must not be empty", c.name)
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(model) == "" {
|
||||||
|
return nil, fmt.Errorf("%s embed: model is required", c.name)
|
||||||
|
}
|
||||||
|
|
||||||
var resp embeddingsResponse
|
var resp embeddingsResponse
|
||||||
err := c.doJSON(ctx, "/embeddings", embeddingsRequest{
|
err := c.doJSON(ctx, "/embeddings", embeddingsRequest{Input: input, Model: model}, &resp)
|
||||||
Input: input,
|
|
||||||
Model: c.embeddingModel,
|
|
||||||
}, &resp)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -195,141 +170,34 @@ func (c *Client) Embed(ctx context.Context, input string) ([]float32, error) {
|
|||||||
if len(resp.Data) == 0 {
|
if len(resp.Data) == 0 {
|
||||||
return nil, fmt.Errorf("%s embed: no embedding returned", c.name)
|
return nil, fmt.Errorf("%s embed: no embedding returned", c.name)
|
||||||
}
|
}
|
||||||
if c.dimensions > 0 && len(resp.Data[0].Embedding) != c.dimensions {
|
|
||||||
return nil, fmt.Errorf("%s embed: expected %d dimensions, got %d", c.name, c.dimensions, len(resp.Data[0].Embedding))
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.Data[0].Embedding, nil
|
return resp.Data[0].Embedding, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ExtractMetadata(ctx context.Context, input string) (thoughttypes.ThoughtMetadata, error) {
|
// ExtractMetadataWith extracts structured metadata for input using opts.Model.
|
||||||
|
// Returns compat.ErrEmptyResponse / ErrNoJSONObject wrapped when the model
|
||||||
|
// produces unusable output so callers can classify the failure.
|
||||||
|
func (c *Client) ExtractMetadataWith(ctx context.Context, opts MetadataOptions, input string) (thoughttypes.ThoughtMetadata, error) {
|
||||||
input = strings.TrimSpace(input)
|
input = strings.TrimSpace(input)
|
||||||
if input == "" {
|
if input == "" {
|
||||||
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s extract metadata: input must not be empty", c.name)
|
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s extract metadata: input must not be empty", c.name)
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(opts.Model) == "" {
|
||||||
start := time.Now()
|
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s extract metadata: model is required", c.name)
|
||||||
if c.log != nil {
|
|
||||||
c.log.Info("metadata client started",
|
|
||||||
slog.String("provider", c.name),
|
|
||||||
slog.String("model", c.metadataModel),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
logCompletion := func(model string, err error) {
|
|
||||||
if c.log == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := []any{
|
|
||||||
slog.String("provider", c.name),
|
|
||||||
slog.String("model", model),
|
|
||||||
slog.String("duration", formatLogDuration(time.Since(start))),
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
attrs = append(attrs, slog.String("error", err.Error()))
|
|
||||||
c.log.Error("metadata client completed", attrs...)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.log.Info("metadata client completed", attrs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := c.extractMetadataWithModel(ctx, input, c.metadataModel)
|
|
||||||
if errors.Is(err, errMetadataEmptyResponse) {
|
|
||||||
c.noteEmptyResponse(c.metadataModel)
|
|
||||||
}
|
|
||||||
if isPermanentModelError(err) {
|
|
||||||
c.notePermanentModelFailure(c.metadataModel, err)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
c.noteModelSuccess(c.metadataModel)
|
|
||||||
logCompletion(c.metadataModel, nil)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fallbackModel := range c.fallbackMetadataModels {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if fallbackModel == "" || fallbackModel == c.metadataModel {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c.shouldBypassModel(fallbackModel) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c.log != nil {
|
|
||||||
c.log.Warn("metadata extraction failed, trying fallback model",
|
|
||||||
slog.String("provider", c.name),
|
|
||||||
slog.String("primary_model", c.metadataModel),
|
|
||||||
slog.String("fallback_model", fallbackModel),
|
|
||||||
slog.String("error", err.Error()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
fallbackResult, fallbackErr := c.extractMetadataWithModel(ctx, input, fallbackModel)
|
|
||||||
if errors.Is(fallbackErr, errMetadataEmptyResponse) {
|
|
||||||
c.noteEmptyResponse(fallbackModel)
|
|
||||||
}
|
|
||||||
if isPermanentModelError(fallbackErr) {
|
|
||||||
c.notePermanentModelFailure(fallbackModel, fallbackErr)
|
|
||||||
}
|
|
||||||
if fallbackErr == nil {
|
|
||||||
c.noteModelSuccess(fallbackModel)
|
|
||||||
logCompletion(fallbackModel, nil)
|
|
||||||
return fallbackResult, nil
|
|
||||||
}
|
|
||||||
err = fallbackErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
err = fmt.Errorf("%s metadata: %w", c.name, ctx.Err())
|
|
||||||
logCompletion(c.metadataModel, err)
|
|
||||||
return thoughttypes.ThoughtMetadata{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
heuristic := heuristicMetadataFromInput(input)
|
|
||||||
if c.log != nil {
|
|
||||||
c.log.Warn("metadata extraction failed for all models, using heuristic fallback",
|
|
||||||
slog.String("provider", c.name),
|
|
||||||
slog.String("error", err.Error()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
logCompletion(c.metadataModel, nil)
|
|
||||||
return heuristic, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatLogDuration(d time.Duration) string {
|
|
||||||
if d < 0 {
|
|
||||||
d = -d
|
|
||||||
}
|
|
||||||
|
|
||||||
totalMilliseconds := d.Milliseconds()
|
|
||||||
minutes := totalMilliseconds / 60000
|
|
||||||
seconds := (totalMilliseconds / 1000) % 60
|
|
||||||
milliseconds := totalMilliseconds % 1000
|
|
||||||
return fmt.Sprintf("%02d:%02d:%03d", minutes, seconds, milliseconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) extractMetadataWithModel(ctx context.Context, input, model string) (thoughttypes.ThoughtMetadata, error) {
|
|
||||||
if c.shouldBypassModel(model) {
|
|
||||||
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: model %q temporarily bypassed after repeated empty responses", c.name, model)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stream := true
|
stream := true
|
||||||
req := chatCompletionsRequest{
|
req := chatCompletionsRequest{
|
||||||
Model: model,
|
Model: opts.Model,
|
||||||
Temperature: c.temperature,
|
Temperature: opts.Temperature,
|
||||||
ResponseFormat: &responseType{
|
ResponseFormat: &responseType{Type: "json_object"},
|
||||||
Type: "json_object",
|
Stream: &stream,
|
||||||
},
|
|
||||||
Stream: &stream,
|
|
||||||
Messages: []chatMessage{
|
Messages: []chatMessage{
|
||||||
{Role: "system", Content: metadataSystemPrompt},
|
{Role: "system", Content: metadataSystemPrompt},
|
||||||
{Role: "user", Content: input},
|
{Role: "user", Content: input},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata, err := c.extractMetadataWithRequest(ctx, req, input, model)
|
metadata, err := c.extractMetadataWithRequest(ctx, req, input, opts)
|
||||||
if err == nil || !shouldRetryWithoutJSONMode(err) {
|
if err == nil || !shouldRetryWithoutJSONMode(err) {
|
||||||
return metadata, err
|
return metadata, err
|
||||||
}
|
}
|
||||||
@@ -337,23 +205,22 @@ func (c *Client) extractMetadataWithModel(ctx context.Context, input, model stri
|
|||||||
if c.log != nil {
|
if c.log != nil {
|
||||||
c.log.Warn("metadata json mode failed, retrying without response_format",
|
c.log.Warn("metadata json mode failed, retrying without response_format",
|
||||||
slog.String("provider", c.name),
|
slog.String("provider", c.name),
|
||||||
slog.String("model", model),
|
slog.String("model", opts.Model),
|
||||||
slog.String("error", err.Error()),
|
slog.String("error", err.Error()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.ResponseFormat = nil
|
req.ResponseFormat = nil
|
||||||
return c.extractMetadataWithRequest(ctx, req, input, model)
|
return c.extractMetadataWithRequest(ctx, req, input, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) extractMetadataWithRequest(ctx context.Context, req chatCompletionsRequest, input, model string) (thoughttypes.ThoughtMetadata, error) {
|
func (c *Client) extractMetadataWithRequest(ctx context.Context, req chatCompletionsRequest, input string, opts MetadataOptions) (thoughttypes.ThoughtMetadata, error) {
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 1; attempt <= maxMetadataAttempts; attempt++ {
|
for attempt := 1; attempt <= maxMetadataAttempts; attempt++ {
|
||||||
if c.logConversations && c.log != nil {
|
if opts.LogConversations && c.log != nil {
|
||||||
c.log.Info("metadata conversation request",
|
c.log.Info("metadata conversation request",
|
||||||
slog.String("provider", c.name),
|
slog.String("provider", c.name),
|
||||||
slog.String("model", model),
|
slog.String("model", opts.Model),
|
||||||
slog.Int("attempt", attempt),
|
slog.Int("attempt", attempt),
|
||||||
slog.String("system", metadataSystemPrompt),
|
slog.String("system", metadataSystemPrompt),
|
||||||
slog.String("input", input),
|
slog.String("input", input),
|
||||||
@@ -373,10 +240,10 @@ func (c *Client) extractMetadataWithRequest(ctx context.Context, req chatComplet
|
|||||||
|
|
||||||
rawResponse := extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text)
|
rawResponse := extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text)
|
||||||
|
|
||||||
if c.logConversations && c.log != nil {
|
if opts.LogConversations && c.log != nil {
|
||||||
c.log.Info("metadata conversation response",
|
c.log.Info("metadata conversation response",
|
||||||
slog.String("provider", c.name),
|
slog.String("provider", c.name),
|
||||||
slog.String("model", model),
|
slog.String("model", opts.Model),
|
||||||
slog.Int("attempt", attempt),
|
slog.Int("attempt", attempt),
|
||||||
slog.String("response", rawResponse),
|
slog.String("response", rawResponse),
|
||||||
)
|
)
|
||||||
@@ -387,13 +254,13 @@ func (c *Client) extractMetadataWithRequest(ctx context.Context, req chatComplet
|
|||||||
metadataText = stripCodeFence(metadataText)
|
metadataText = stripCodeFence(metadataText)
|
||||||
metadataText = extractJSONObject(metadataText)
|
metadataText = extractJSONObject(metadataText)
|
||||||
if metadataText == "" {
|
if metadataText == "" {
|
||||||
lastErr = fmt.Errorf("%s metadata: %w", c.name, errMetadataNoJSONObject)
|
lastErr = fmt.Errorf("%s metadata: %w", c.name, ErrNoJSONObject)
|
||||||
if strings.TrimSpace(rawResponse) == "" && attempt < maxMetadataAttempts && ctx.Err() == nil {
|
if strings.TrimSpace(rawResponse) == "" && attempt < maxMetadataAttempts && ctx.Err() == nil {
|
||||||
lastErr = fmt.Errorf("%s metadata: %w", c.name, errMetadataEmptyResponse)
|
lastErr = fmt.Errorf("%s metadata: %w", c.name, ErrEmptyResponse)
|
||||||
if c.log != nil {
|
if c.log != nil {
|
||||||
c.log.Warn("metadata response empty, waiting and retrying",
|
c.log.Warn("metadata response empty, waiting and retrying",
|
||||||
slog.String("provider", c.name),
|
slog.String("provider", c.name),
|
||||||
slog.String("model", model),
|
slog.String("model", opts.Model),
|
||||||
slog.Int("attempt", attempt+1),
|
slog.Int("attempt", attempt+1),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -403,7 +270,7 @@ func (c *Client) extractMetadataWithRequest(ctx context.Context, req chatComplet
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(rawResponse) == "" {
|
if strings.TrimSpace(rawResponse) == "" {
|
||||||
lastErr = fmt.Errorf("%s metadata: %w", c.name, errMetadataEmptyResponse)
|
lastErr = fmt.Errorf("%s metadata: %w", c.name, ErrEmptyResponse)
|
||||||
}
|
}
|
||||||
return thoughttypes.ThoughtMetadata{}, lastErr
|
return thoughttypes.ThoughtMetadata{}, lastErr
|
||||||
}
|
}
|
||||||
@@ -420,13 +287,17 @@ func (c *Client) extractMetadataWithRequest(ctx context.Context, req chatComplet
|
|||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
return thoughttypes.ThoughtMetadata{}, lastErr
|
return thoughttypes.ThoughtMetadata{}, lastErr
|
||||||
}
|
}
|
||||||
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: %w", c.name, errMetadataNoJSONObject)
|
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: %w", c.name, ErrNoJSONObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Summarize(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
// SummarizeWith runs a chat-completion summarisation using opts.Model.
|
||||||
|
func (c *Client) SummarizeWith(ctx context.Context, opts SummarizeOptions, systemPrompt, userPrompt string) (string, error) {
|
||||||
|
if strings.TrimSpace(opts.Model) == "" {
|
||||||
|
return "", fmt.Errorf("%s summarize: model is required", c.name)
|
||||||
|
}
|
||||||
req := chatCompletionsRequest{
|
req := chatCompletionsRequest{
|
||||||
Model: c.metadataModel,
|
Model: opts.Model,
|
||||||
Temperature: 0.2,
|
Temperature: opts.Temperature,
|
||||||
Messages: []chatMessage{
|
Messages: []chatMessage{
|
||||||
{Role: "system", Content: systemPrompt},
|
{Role: "system", Content: systemPrompt},
|
||||||
{Role: "user", Content: userPrompt},
|
{Role: "user", Content: userPrompt},
|
||||||
@@ -447,12 +318,49 @@ func (c *Client) Summarize(ctx context.Context, systemPrompt, userPrompt string)
|
|||||||
return extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text), nil
|
return extractChoiceText(resp.Choices[0].Message, resp.Choices[0].Text), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Name() string {
|
// IsPermanentModelError reports whether err indicates the model itself is
|
||||||
return c.name
|
// invalid or missing (vs. a transient outage). Runners use this to mark a
|
||||||
|
// target unhealthy for longer.
|
||||||
|
func IsPermanentModelError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(err.Error())
|
||||||
|
for _, marker := range []string{
|
||||||
|
"invalid model name",
|
||||||
|
"model_not_found",
|
||||||
|
"model not found",
|
||||||
|
"unknown model",
|
||||||
|
"no such model",
|
||||||
|
"does not exist",
|
||||||
|
} {
|
||||||
|
if strings.Contains(lower, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) EmbeddingModel() string {
|
// HeuristicMetadataFromInput produces best-effort metadata from the note text
|
||||||
return c.embeddingModel
|
// when every model in the chain has failed. Exported so ai.Runner can use it.
|
||||||
|
func HeuristicMetadataFromInput(input string) thoughttypes.ThoughtMetadata {
|
||||||
|
text := strings.TrimSpace(input)
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
|
||||||
|
metadata := thoughttypes.ThoughtMetadata{
|
||||||
|
People: heuristicPeople(text),
|
||||||
|
ActionItems: heuristicActionItems(text),
|
||||||
|
DatesMentioned: heuristicDates(text),
|
||||||
|
Topics: heuristicTopics(lower),
|
||||||
|
Type: heuristicType(lower),
|
||||||
|
}
|
||||||
|
if len(metadata.Topics) == 0 {
|
||||||
|
metadata.Topics = []string{"uncategorized"}
|
||||||
|
}
|
||||||
|
if metadata.Type == "" {
|
||||||
|
metadata.Type = "observation"
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) doJSON(ctx context.Context, path string, requestBody any, dest any) error {
|
func (c *Client) doJSON(ctx context.Context, path string, requestBody any, dest any) error {
|
||||||
@@ -724,8 +632,6 @@ func isRetryableChatResponseError(err error) bool {
|
|||||||
return strings.Contains(lower, "read response") || strings.Contains(lower, "read stream response")
|
return strings.Contains(lower, "read response") || strings.Contains(lower, "read stream response")
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractJSONObject finds the first complete {...} block in s.
|
|
||||||
// It handles models that prepend prose to a JSON response despite json_object mode.
|
|
||||||
func extractJSONObject(s string) string {
|
func extractJSONObject(s string) string {
|
||||||
for start := 0; start < len(s); start++ {
|
for start := 0; start < len(s); start++ {
|
||||||
if s[start] != '{' {
|
if s[start] != '{' {
|
||||||
@@ -768,10 +674,6 @@ func extractJSONObject(s string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripThinkingBlocks removes <think>...</think> and <thinking>...</thinking>
|
|
||||||
// blocks produced by reasoning models (DeepSeek R1, QwQ, etc.) so that the
|
|
||||||
// remaining text can be parsed as JSON without interference from thinking content
|
|
||||||
// that may itself contain braces.
|
|
||||||
func stripThinkingBlocks(s string) string {
|
func stripThinkingBlocks(s string) string {
|
||||||
for _, tag := range []string{"think", "thinking"} {
|
for _, tag := range []string{"think", "thinking"} {
|
||||||
open := "<" + tag + ">"
|
open := "<" + tag + ">"
|
||||||
@@ -857,7 +759,6 @@ func extractTextFromAny(value any) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
// Common provider shapes for chat content parts.
|
|
||||||
for _, key := range []string{"text", "output_text", "content", "value"} {
|
for _, key := range []string{"text", "output_text", "content", "value"} {
|
||||||
if nested, ok := typed[key]; ok {
|
if nested, ok := typed[key]; ok {
|
||||||
if text := strings.TrimSpace(extractTextFromAny(nested)); text != "" {
|
if text := strings.TrimSpace(extractTextFromAny(nested)); text != "" {
|
||||||
@@ -875,28 +776,6 @@ var (
|
|||||||
wordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_/-]{2,}`)
|
wordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_/-]{2,}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func heuristicMetadataFromInput(input string) thoughttypes.ThoughtMetadata {
|
|
||||||
text := strings.TrimSpace(input)
|
|
||||||
lower := strings.ToLower(text)
|
|
||||||
|
|
||||||
metadata := thoughttypes.ThoughtMetadata{
|
|
||||||
People: heuristicPeople(text),
|
|
||||||
ActionItems: heuristicActionItems(text),
|
|
||||||
DatesMentioned: heuristicDates(text),
|
|
||||||
Topics: heuristicTopics(lower),
|
|
||||||
Type: heuristicType(lower),
|
|
||||||
Source: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(metadata.Topics) == 0 {
|
|
||||||
metadata.Topics = []string{"uncategorized"}
|
|
||||||
}
|
|
||||||
if metadata.Type == "" {
|
|
||||||
metadata.Type = "observation"
|
|
||||||
}
|
|
||||||
return metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
func heuristicType(lower string) string {
|
func heuristicType(lower string) string {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(lower, "preferred name"), strings.Contains(lower, "personal profile"), strings.Contains(lower, "wife:"), strings.Contains(lower, "daughter:"), strings.Contains(lower, "born:"):
|
case strings.Contains(lower, "preferred name"), strings.Contains(lower, "personal profile"), strings.Contains(lower, "wife:"), strings.Contains(lower, "daughter:"), strings.Contains(lower, "born:"):
|
||||||
@@ -1055,7 +934,7 @@ func shouldRetryWithoutJSONMode(err error) bool {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if errors.Is(err, errMetadataEmptyResponse) || errors.Is(err, errMetadataNoJSONObject) {
|
if errors.Is(err, ErrEmptyResponse) || errors.Is(err, ErrNoJSONObject) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,27 +942,6 @@ func shouldRetryWithoutJSONMode(err error) bool {
|
|||||||
return strings.Contains(lower, "parse json")
|
return strings.Contains(lower, "parse json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPermanentModelError(err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
lower := strings.ToLower(err.Error())
|
|
||||||
for _, marker := range []string{
|
|
||||||
"invalid model name",
|
|
||||||
"model_not_found",
|
|
||||||
"model not found",
|
|
||||||
"unknown model",
|
|
||||||
"no such model",
|
|
||||||
"does not exist",
|
|
||||||
} {
|
|
||||||
if strings.Contains(lower, marker) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func sleepRetry(ctx context.Context, attempt int, log *slog.Logger, provider string) error {
|
func sleepRetry(ctx context.Context, attempt int, log *slog.Logger, provider string) error {
|
||||||
delay := time.Duration(attempt*attempt) * 200 * time.Millisecond
|
delay := time.Duration(attempt*attempt) * 200 * time.Millisecond
|
||||||
if log != nil {
|
if log != nil {
|
||||||
@@ -1110,59 +968,3 @@ func sleepMetadataRetry(ctx context.Context, attempt int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) shouldBypassModel(model string) bool {
|
|
||||||
c.modelHealthMu.Lock()
|
|
||||||
defer c.modelHealthMu.Unlock()
|
|
||||||
|
|
||||||
state, ok := c.modelHealth[model]
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return !state.unhealthyUntil.IsZero() && time.Now().Before(state.unhealthyUntil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) noteEmptyResponse(model string) {
|
|
||||||
c.modelHealthMu.Lock()
|
|
||||||
defer c.modelHealthMu.Unlock()
|
|
||||||
|
|
||||||
state := c.modelHealth[model]
|
|
||||||
state.consecutiveEmpty++
|
|
||||||
if state.consecutiveEmpty >= emptyResponseCircuitThreshold {
|
|
||||||
state.unhealthyUntil = time.Now().Add(emptyResponseCircuitTTL)
|
|
||||||
if c.log != nil {
|
|
||||||
c.log.Warn("metadata model marked temporarily unhealthy after repeated empty responses",
|
|
||||||
slog.String("provider", c.name),
|
|
||||||
slog.String("model", model),
|
|
||||||
slog.Time("until", state.unhealthyUntil),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.modelHealth[model] = state
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) noteModelSuccess(model string) {
|
|
||||||
c.modelHealthMu.Lock()
|
|
||||||
defer c.modelHealthMu.Unlock()
|
|
||||||
|
|
||||||
delete(c.modelHealth, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) notePermanentModelFailure(model string, err error) {
|
|
||||||
c.modelHealthMu.Lock()
|
|
||||||
defer c.modelHealthMu.Unlock()
|
|
||||||
|
|
||||||
state := c.modelHealth[model]
|
|
||||||
state.consecutiveEmpty = emptyResponseCircuitThreshold
|
|
||||||
state.unhealthyUntil = time.Now().Add(permanentModelFailureTTL)
|
|
||||||
c.modelHealth[model] = state
|
|
||||||
|
|
||||||
if c.log != nil {
|
|
||||||
c.log.Warn("metadata model marked unhealthy after permanent failure",
|
|
||||||
slog.String("provider", c.name),
|
|
||||||
slog.String("model", model),
|
|
||||||
slog.String("error", err.Error()),
|
|
||||||
slog.Time("until", state.unhealthyUntil),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func newTestClient(t *testing.T, url string) *Client {
|
||||||
|
t.Helper()
|
||||||
|
return New(Config{
|
||||||
|
Name: "litellm",
|
||||||
|
BaseURL: url,
|
||||||
|
APIKey: "test-key",
|
||||||
|
HTTPClient: http.DefaultClient,
|
||||||
|
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractMetadataFromStreamingResponse(t *testing.T) {
|
func TestExtractMetadataFromStreamingResponse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -26,6 +37,9 @@ func TestExtractMetadataFromStreamingResponse(t *testing.T) {
|
|||||||
if req.Stream == nil || !*req.Stream {
|
if req.Stream == nil || !*req.Stream {
|
||||||
t.Fatalf("stream flag = %v, want true", req.Stream)
|
t.Fatalf("stream flag = %v, want true", req.Stream)
|
||||||
}
|
}
|
||||||
|
if req.Model != "qwen3.5:latest" {
|
||||||
|
t.Fatalf("model = %q, want qwen3.5:latest", req.Model)
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"{\\\"people\\\":[],\"}}]}\n\n")
|
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"{\\\"people\\\":[],\"}}]}\n\n")
|
||||||
@@ -35,20 +49,13 @@ func TestExtractMetadataFromStreamingResponse(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := New(Config{
|
client := newTestClient(t, server.URL)
|
||||||
Name: "litellm",
|
metadata, err := client.ExtractMetadataWith(context.Background(), MetadataOptions{
|
||||||
BaseURL: server.URL,
|
Model: "qwen3.5:latest",
|
||||||
APIKey: "test-key",
|
Temperature: 0.1,
|
||||||
MetadataModel: "qwen3.5:latest",
|
}, "Project idea: Build an Android companion app.")
|
||||||
Temperature: 0.1,
|
|
||||||
HTTPClient: server.Client(),
|
|
||||||
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
||||||
EmbeddingModel: "unused",
|
|
||||||
})
|
|
||||||
|
|
||||||
metadata, err := client.ExtractMetadata(context.Background(), "Project idea: Build an Android companion app.")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ExtractMetadata() error = %v", err)
|
t.Fatalf("ExtractMetadataWith() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Type != "idea" {
|
if metadata.Type != "idea" {
|
||||||
@@ -94,20 +101,13 @@ func TestExtractMetadataRetriesWithoutJSONMode(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := New(Config{
|
client := newTestClient(t, server.URL)
|
||||||
Name: "litellm",
|
metadata, err := client.ExtractMetadataWith(context.Background(), MetadataOptions{
|
||||||
BaseURL: server.URL,
|
Model: "qwen3.5:latest",
|
||||||
APIKey: "test-key",
|
Temperature: 0.1,
|
||||||
MetadataModel: "qwen3.5:latest",
|
}, "Project idea: Build an Android companion app.")
|
||||||
Temperature: 0.1,
|
|
||||||
HTTPClient: server.Client(),
|
|
||||||
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
||||||
EmbeddingModel: "unused",
|
|
||||||
})
|
|
||||||
|
|
||||||
metadata, err := client.ExtractMetadata(context.Background(), "Project idea: Build an Android companion app.")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ExtractMetadata() error = %v", err)
|
t.Fatalf("ExtractMetadataWith() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Type != "idea" {
|
if metadata.Type != "idea" {
|
||||||
@@ -127,71 +127,33 @@ func TestExtractMetadataRetriesWithoutJSONMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractMetadataBypassesInvalidFallbackModelAfterFirstFailure(t *testing.T) {
|
func TestIsPermanentModelError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var mu sync.Mutex
|
cases := []struct {
|
||||||
primaryCalls := 0
|
name string
|
||||||
invalidFallbackCalls := 0
|
err error
|
||||||
|
want bool
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
}{
|
||||||
defer func() {
|
{"nil", nil, false},
|
||||||
_ = r.Body.Close()
|
{"invalid model", errMsg("Invalid model name passed in model=qwen3"), true},
|
||||||
}()
|
{"model not found", errMsg("model_not_found"), true},
|
||||||
|
{"no such model", errMsg("no such model"), true},
|
||||||
var req chatCompletionsRequest
|
{"transient", errMsg("connection refused"), false},
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
t.Fatalf("decode request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch req.Model {
|
|
||||||
case "empty-primary":
|
|
||||||
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":""}}]}`)
|
|
||||||
case "qwen3.5:latest":
|
|
||||||
mu.Lock()
|
|
||||||
primaryCalls++
|
|
||||||
mu.Unlock()
|
|
||||||
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"{\"people\":[],\"action_items\":[],\"dates_mentioned\":[],\"topics\":[\"metadata\"],\"type\":\"observation\",\"source\":\"primary\"}"}}]}`)
|
|
||||||
case "qwen3":
|
|
||||||
mu.Lock()
|
|
||||||
invalidFallbackCalls++
|
|
||||||
mu.Unlock()
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
_, _ = io.WriteString(w, "{\"error\":{\"message\":\"{'error': '/chat/completions: Invalid model name passed in model=qwen3. Call `/v1/models` to view available models for your key.'}\"}}")
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected model %q", req.Model)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := New(Config{
|
|
||||||
Name: "litellm",
|
|
||||||
BaseURL: server.URL,
|
|
||||||
APIKey: "test-key",
|
|
||||||
MetadataModel: "empty-primary",
|
|
||||||
FallbackMetadataModels: []string{"qwen3", "qwen3.5:latest"},
|
|
||||||
Temperature: 0.1,
|
|
||||||
HTTPClient: server.Client(),
|
|
||||||
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
||||||
EmbeddingModel: "unused",
|
|
||||||
})
|
|
||||||
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
metadata, err := client.ExtractMetadata(context.Background(), "A short note about metadata.")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ExtractMetadata() error = %v", err)
|
|
||||||
}
|
|
||||||
if metadata.Source != "primary" {
|
|
||||||
t.Fatalf("metadata source = %q, want primary", metadata.Source)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mu.Lock()
|
for _, tc := range cases {
|
||||||
defer mu.Unlock()
|
tc := tc
|
||||||
if invalidFallbackCalls != 1 {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Fatalf("invalid fallback calls = %d, want 1", invalidFallbackCalls)
|
if got := IsPermanentModelError(tc.err); got != tc.want {
|
||||||
}
|
t.Fatalf("IsPermanentModelError(%v) = %v, want %v", tc.err, got, tc.want)
|
||||||
if primaryCalls != 2 {
|
}
|
||||||
t.Fatalf("valid fallback calls = %d, want 2", primaryCalls)
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stringError string
|
||||||
|
|
||||||
|
func (s stringError) Error() string { return string(s) }
|
||||||
|
|
||||||
|
func errMsg(s string) error { return stringError(s) }
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package ai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/ai/litellm"
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/ai/ollama"
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/ai/openrouter"
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewProvider(cfg config.AIConfig, httpClient *http.Client, log *slog.Logger) (Provider, error) {
|
|
||||||
switch cfg.Provider {
|
|
||||||
case "litellm":
|
|
||||||
return litellm.New(cfg, httpClient, log)
|
|
||||||
case "ollama":
|
|
||||||
return ollama.New(cfg, httpClient, log)
|
|
||||||
case "openrouter":
|
|
||||||
return openrouter.New(cfg, httpClient, log)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported ai.provider: %s", cfg.Provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package ai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewProviderSupportsOllama(t *testing.T) {
|
|
||||||
provider, err := NewProvider(config.AIConfig{
|
|
||||||
Provider: "ollama",
|
|
||||||
Embeddings: config.AIEmbeddingConfig{
|
|
||||||
Model: "nomic-embed-text",
|
|
||||||
Dimensions: 768,
|
|
||||||
},
|
|
||||||
Metadata: config.AIMetadataConfig{
|
|
||||||
Model: "llama3.2",
|
|
||||||
},
|
|
||||||
Ollama: config.OllamaConfig{
|
|
||||||
BaseURL: "http://localhost:11434/v1",
|
|
||||||
APIKey: "ollama",
|
|
||||||
},
|
|
||||||
}, &http.Client{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewProvider() error = %v", err)
|
|
||||||
}
|
|
||||||
if provider.Name() != "ollama" {
|
|
||||||
t.Fatalf("provider name = %q, want ollama", provider.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package litellm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/ai/compat"
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func New(cfg config.AIConfig, httpClient *http.Client, log *slog.Logger) (*compat.Client, error) {
|
|
||||||
fallbacks := cfg.LiteLLM.EffectiveFallbackMetadataModels()
|
|
||||||
if len(fallbacks) == 0 {
|
|
||||||
fallbacks = cfg.Metadata.EffectiveFallbackModels()
|
|
||||||
}
|
|
||||||
return compat.New(compat.Config{
|
|
||||||
Name: "litellm",
|
|
||||||
BaseURL: cfg.LiteLLM.BaseURL,
|
|
||||||
APIKey: cfg.LiteLLM.APIKey,
|
|
||||||
EmbeddingModel: cfg.LiteLLM.EmbeddingModel,
|
|
||||||
MetadataModel: cfg.LiteLLM.MetadataModel,
|
|
||||||
FallbackMetadataModels: fallbacks,
|
|
||||||
Temperature: cfg.Metadata.Temperature,
|
|
||||||
Headers: cfg.LiteLLM.RequestHeaders,
|
|
||||||
HTTPClient: httpClient,
|
|
||||||
Log: log,
|
|
||||||
Dimensions: cfg.Embeddings.Dimensions,
|
|
||||||
LogConversations: cfg.Metadata.LogConversations,
|
|
||||||
}), nil
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package ollama
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/ai/compat"
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func New(cfg config.AIConfig, httpClient *http.Client, log *slog.Logger) (*compat.Client, error) {
|
|
||||||
return compat.New(compat.Config{
|
|
||||||
Name: "ollama",
|
|
||||||
BaseURL: cfg.Ollama.BaseURL,
|
|
||||||
APIKey: cfg.Ollama.APIKey,
|
|
||||||
EmbeddingModel: cfg.Embeddings.Model,
|
|
||||||
MetadataModel: cfg.Metadata.Model,
|
|
||||||
FallbackMetadataModels: cfg.Metadata.EffectiveFallbackModels(),
|
|
||||||
Temperature: cfg.Metadata.Temperature,
|
|
||||||
Headers: cfg.Ollama.RequestHeaders,
|
|
||||||
HTTPClient: httpClient,
|
|
||||||
Log: log,
|
|
||||||
Dimensions: cfg.Embeddings.Dimensions,
|
|
||||||
LogConversations: cfg.Metadata.LogConversations,
|
|
||||||
}), nil
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package openrouter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/ai/compat"
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func New(cfg config.AIConfig, httpClient *http.Client, log *slog.Logger) (*compat.Client, error) {
|
|
||||||
headers := make(map[string]string, len(cfg.OpenRouter.ExtraHeaders)+2)
|
|
||||||
for key, value := range cfg.OpenRouter.ExtraHeaders {
|
|
||||||
headers[key] = value
|
|
||||||
}
|
|
||||||
if cfg.OpenRouter.SiteURL != "" {
|
|
||||||
headers["HTTP-Referer"] = cfg.OpenRouter.SiteURL
|
|
||||||
}
|
|
||||||
if cfg.OpenRouter.AppName != "" {
|
|
||||||
headers["X-Title"] = cfg.OpenRouter.AppName
|
|
||||||
}
|
|
||||||
|
|
||||||
return compat.New(compat.Config{
|
|
||||||
Name: "openrouter",
|
|
||||||
BaseURL: cfg.OpenRouter.BaseURL,
|
|
||||||
APIKey: cfg.OpenRouter.APIKey,
|
|
||||||
EmbeddingModel: cfg.Embeddings.Model,
|
|
||||||
MetadataModel: cfg.Metadata.Model,
|
|
||||||
FallbackMetadataModels: cfg.Metadata.EffectiveFallbackModels(),
|
|
||||||
Temperature: cfg.Metadata.Temperature,
|
|
||||||
Headers: headers,
|
|
||||||
HTTPClient: httpClient,
|
|
||||||
Log: log,
|
|
||||||
Dimensions: cfg.Embeddings.Dimensions,
|
|
||||||
LogConversations: cfg.Metadata.LogConversations,
|
|
||||||
}), nil
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package ai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Provider interface {
|
|
||||||
Embed(ctx context.Context, input string) ([]float32, error)
|
|
||||||
ExtractMetadata(ctx context.Context, input string) (thoughttypes.ThoughtMetadata, error)
|
|
||||||
Summarize(ctx context.Context, systemPrompt, userPrompt string) (string, error)
|
|
||||||
Name() string
|
|
||||||
EmbeddingModel() string
|
|
||||||
}
|
|
||||||
96
internal/ai/registry.go
Normal file
96
internal/ai/registry.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/ai/compat"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry holds one compat.Client per named provider. Runners look up clients
|
||||||
|
// by provider name when walking a role chain.
|
||||||
|
type Registry struct {
|
||||||
|
clients map[string]*compat.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry builds a Registry from the configured providers. Each provider
|
||||||
|
// type maps onto a compat.Client with type-specific header plumbing (e.g.
|
||||||
|
// openrouter's HTTP-Referer / X-Title).
|
||||||
|
func NewRegistry(providers map[string]config.ProviderConfig, httpClient *http.Client, log *slog.Logger) (*Registry, error) {
|
||||||
|
if httpClient == nil {
|
||||||
|
return nil, fmt.Errorf("ai registry: http client is required")
|
||||||
|
}
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return nil, fmt.Errorf("ai registry: no providers configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
clients := make(map[string]*compat.Client, len(providers))
|
||||||
|
for name, p := range providers {
|
||||||
|
headers, err := providerHeaders(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ai registry: provider %q: %w", name, err)
|
||||||
|
}
|
||||||
|
clients[name] = compat.New(compat.Config{
|
||||||
|
Name: name,
|
||||||
|
BaseURL: p.BaseURL,
|
||||||
|
APIKey: p.APIKey,
|
||||||
|
Headers: headers,
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
Log: log,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &Registry{clients: clients}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client returns the compat.Client registered under name.
|
||||||
|
func (r *Registry) Client(name string) (*compat.Client, error) {
|
||||||
|
c, ok := r.clients[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("ai registry: provider %q is not configured", name)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names returns the registered provider names.
|
||||||
|
func (r *Registry) Names() []string {
|
||||||
|
names := make([]string, 0, len(r.clients))
|
||||||
|
for name := range r.clients {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerHeaders(p config.ProviderConfig) (map[string]string, error) {
|
||||||
|
switch p.Type {
|
||||||
|
case "litellm", "ollama":
|
||||||
|
return cloneHeaders(p.RequestHeaders), nil
|
||||||
|
case "openrouter":
|
||||||
|
headers := cloneHeaders(p.RequestHeaders)
|
||||||
|
if headers == nil {
|
||||||
|
headers = map[string]string{}
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(p.SiteURL); s != "" {
|
||||||
|
headers["HTTP-Referer"] = s
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(p.AppName); s != "" {
|
||||||
|
headers["X-Title"] = s
|
||||||
|
}
|
||||||
|
return headers, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported provider type %q", p.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneHeaders(in map[string]string) map[string]string {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(in))
|
||||||
|
for k, v := range in {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
80
internal/ai/registry_test.go
Normal file
80
internal/ai/registry_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/ai/compat"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRegistryOpenRouterHeaders(t *testing.T) {
|
||||||
|
var (
|
||||||
|
gotReferer string
|
||||||
|
gotTitle string
|
||||||
|
gotCustom string
|
||||||
|
)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotReferer = r.Header.Get("HTTP-Referer")
|
||||||
|
gotTitle = r.Header.Get("X-Title")
|
||||||
|
gotCustom = r.Header.Get("X-Custom")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "ok"}}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
providers := map[string]config.ProviderConfig{
|
||||||
|
"router": {
|
||||||
|
Type: "openrouter",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
APIKey: "secret",
|
||||||
|
RequestHeaders: map[string]string{
|
||||||
|
"X-Custom": "value",
|
||||||
|
},
|
||||||
|
AppName: "amcs",
|
||||||
|
SiteURL: "https://example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := NewRegistry(providers, srv.Client(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := reg.Client("router")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Client(router) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.SummarizeWith(context.Background(), compat.SummarizeOptions{Model: "gpt-4.1-mini"}, "system", "user"); err != nil {
|
||||||
|
t.Fatalf("SummarizeWith() error = %v", err)
|
||||||
|
}
|
||||||
|
if gotReferer != "https://example.com" {
|
||||||
|
t.Fatalf("HTTP-Referer = %q, want https://example.com", gotReferer)
|
||||||
|
}
|
||||||
|
if gotTitle != "amcs" {
|
||||||
|
t.Fatalf("X-Title = %q, want amcs", gotTitle)
|
||||||
|
}
|
||||||
|
if gotCustom != "value" {
|
||||||
|
t.Fatalf("X-Custom = %q, want value", gotCustom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRegistryRejectsUnsupportedProviderType(t *testing.T) {
|
||||||
|
providers := map[string]config.ProviderConfig{
|
||||||
|
"bad": {
|
||||||
|
Type: "unknown",
|
||||||
|
BaseURL: "http://localhost:4000/v1",
|
||||||
|
APIKey: "secret",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := NewRegistry(providers, &http.Client{}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("NewRegistry() error = nil, want unsupported provider type error")
|
||||||
|
}
|
||||||
|
}
|
||||||
367
internal/ai/runner.go
Normal file
367
internal/ai/runner.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/ai/compat"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Health TTLs per failure class. These are short enough that a healed target
|
||||||
|
// gets retried without manual intervention, but long enough to avoid hammering
|
||||||
|
// a broken provider every call.
|
||||||
|
const (
|
||||||
|
transientCooldown = 30 * time.Second
|
||||||
|
permanentCooldown = 10 * time.Minute
|
||||||
|
emptyResponseThreshold = 3
|
||||||
|
emptyResponseCooldown = 2 * time.Minute
|
||||||
|
dimensionMismatchWarning = "embedding dimension mismatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmbedResult carries the vector plus the (provider, model) that produced it —
|
||||||
|
// callers store the actual model so later searches against that row use the
|
||||||
|
// matching query embedding.
|
||||||
|
type EmbedResult struct {
|
||||||
|
Vector []float32
|
||||||
|
Provider string
|
||||||
|
Model string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddingRunner executes the embeddings role chain with sequential fallback.
|
||||||
|
type EmbeddingRunner struct {
|
||||||
|
registry *Registry
|
||||||
|
chain []config.RoleTarget
|
||||||
|
dimensions int
|
||||||
|
health *healthTracker
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataRunner executes the metadata role chain with sequential fallback and
|
||||||
|
// a heuristic fallthrough when every target is unhealthy or fails.
|
||||||
|
type MetadataRunner struct {
|
||||||
|
registry *Registry
|
||||||
|
chain []config.RoleTarget
|
||||||
|
opts metadataRunOpts
|
||||||
|
health *healthTracker
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type metadataRunOpts struct {
|
||||||
|
temperature float64
|
||||||
|
logConversations bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEmbeddingRunner builds a runner for the embeddings role. chain must be
|
||||||
|
// non-empty and every target must be registered.
|
||||||
|
func NewEmbeddingRunner(registry *Registry, chain []config.RoleTarget, dimensions int, log *slog.Logger) (*EmbeddingRunner, error) {
|
||||||
|
if registry == nil {
|
||||||
|
return nil, fmt.Errorf("embedding runner: registry is required")
|
||||||
|
}
|
||||||
|
if len(chain) == 0 {
|
||||||
|
return nil, fmt.Errorf("embedding runner: chain is empty")
|
||||||
|
}
|
||||||
|
if dimensions <= 0 {
|
||||||
|
return nil, fmt.Errorf("embedding runner: dimensions must be > 0")
|
||||||
|
}
|
||||||
|
for i, t := range chain {
|
||||||
|
if _, err := registry.Client(t.Provider); err != nil {
|
||||||
|
return nil, fmt.Errorf("embedding runner: chain[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &EmbeddingRunner{
|
||||||
|
registry: registry,
|
||||||
|
chain: chain,
|
||||||
|
dimensions: dimensions,
|
||||||
|
health: newHealthTracker(),
|
||||||
|
log: log,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMetadataRunner builds a runner for the metadata role.
|
||||||
|
func NewMetadataRunner(registry *Registry, chain []config.RoleTarget, temperature float64, logConversations bool, log *slog.Logger) (*MetadataRunner, error) {
|
||||||
|
if registry == nil {
|
||||||
|
return nil, fmt.Errorf("metadata runner: registry is required")
|
||||||
|
}
|
||||||
|
if len(chain) == 0 {
|
||||||
|
return nil, fmt.Errorf("metadata runner: chain is empty")
|
||||||
|
}
|
||||||
|
for i, t := range chain {
|
||||||
|
if _, err := registry.Client(t.Provider); err != nil {
|
||||||
|
return nil, fmt.Errorf("metadata runner: chain[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &MetadataRunner{
|
||||||
|
registry: registry,
|
||||||
|
chain: chain,
|
||||||
|
opts: metadataRunOpts{
|
||||||
|
temperature: temperature,
|
||||||
|
logConversations: logConversations,
|
||||||
|
},
|
||||||
|
health: newHealthTracker(),
|
||||||
|
log: log,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrimaryProvider returns the first provider in the chain.
|
||||||
|
func (r *EmbeddingRunner) PrimaryProvider() string { return r.chain[0].Provider }
|
||||||
|
|
||||||
|
// PrimaryModel returns the first model in the chain — the one used as the
|
||||||
|
// storage key for search matching.
|
||||||
|
func (r *EmbeddingRunner) PrimaryModel() string { return r.chain[0].Model }
|
||||||
|
|
||||||
|
// Dimensions returns the required vector dimension.
|
||||||
|
func (r *EmbeddingRunner) Dimensions() int { return r.dimensions }
|
||||||
|
|
||||||
|
// Embed walks the chain and returns the first successful embedding. The
|
||||||
|
// returned EmbedResult names the actual (provider, model) that produced the
|
||||||
|
// vector — callers use that when recording the row.
|
||||||
|
func (r *EmbeddingRunner) Embed(ctx context.Context, input string) (EmbedResult, error) {
|
||||||
|
var errs []error
|
||||||
|
for _, target := range r.chain {
|
||||||
|
if r.health.skip(target) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client, err := r.registry.Client(target.Provider)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vec, err := client.EmbedWith(ctx, target.Model, input)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return EmbedResult{}, ctx.Err()
|
||||||
|
}
|
||||||
|
r.classify(target, err)
|
||||||
|
r.logFailure("embed", target, err)
|
||||||
|
errs = append(errs, fmt.Errorf("%s/%s: %w", target.Provider, target.Model, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(vec) != r.dimensions {
|
||||||
|
dimErr := fmt.Errorf("%s: expected %d, got %d", dimensionMismatchWarning, r.dimensions, len(vec))
|
||||||
|
r.health.markTransient(target)
|
||||||
|
r.logFailure("embed", target, dimErr)
|
||||||
|
errs = append(errs, fmt.Errorf("%s/%s: %w", target.Provider, target.Model, dimErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.health.markHealthy(target)
|
||||||
|
return EmbedResult{Vector: vec, Provider: target.Provider, Model: target.Model}, nil
|
||||||
|
}
|
||||||
|
return EmbedResult{}, fmt.Errorf("all embedding targets failed: %w", errors.Join(errs...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedPrimary embeds using only the primary target — used for search queries
|
||||||
|
// so the query vector matches rows stored under the primary model. Falls back
|
||||||
|
// to returning the error without walking the chain.
|
||||||
|
func (r *EmbeddingRunner) EmbedPrimary(ctx context.Context, input string) ([]float32, error) {
|
||||||
|
target := r.chain[0]
|
||||||
|
client, err := r.registry.Client(target.Provider)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
vec, err := client.EmbedWith(ctx, target.Model, input)
|
||||||
|
if err != nil {
|
||||||
|
r.classify(target, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(vec) != r.dimensions {
|
||||||
|
return nil, fmt.Errorf("%s: expected %d, got %d", dimensionMismatchWarning, r.dimensions, len(vec))
|
||||||
|
}
|
||||||
|
r.health.markHealthy(target)
|
||||||
|
return vec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrimaryProvider / PrimaryModel for metadata mirror the embedding runner.
|
||||||
|
func (r *MetadataRunner) PrimaryProvider() string { return r.chain[0].Provider }
|
||||||
|
func (r *MetadataRunner) PrimaryModel() string { return r.chain[0].Model }
|
||||||
|
|
||||||
|
// ExtractMetadata walks the chain sequentially. If every target fails or is
|
||||||
|
// unhealthy, it returns a heuristic metadata so capture never hard-fails.
|
||||||
|
func (r *MetadataRunner) ExtractMetadata(ctx context.Context, input string) (thoughttypes.ThoughtMetadata, error) {
|
||||||
|
var errs []error
|
||||||
|
for _, target := range r.chain {
|
||||||
|
if r.health.skip(target) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client, err := r.registry.Client(target.Provider)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
md, err := client.ExtractMetadataWith(ctx, compat.MetadataOptions{
|
||||||
|
Model: target.Model,
|
||||||
|
Temperature: r.opts.temperature,
|
||||||
|
LogConversations: r.opts.logConversations,
|
||||||
|
}, input)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return thoughttypes.ThoughtMetadata{}, ctx.Err()
|
||||||
|
}
|
||||||
|
r.classify(target, err)
|
||||||
|
r.logFailure("metadata", target, err)
|
||||||
|
errs = append(errs, fmt.Errorf("%s/%s: %w", target.Provider, target.Model, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.health.markHealthy(target)
|
||||||
|
return md, nil
|
||||||
|
}
|
||||||
|
if r.log != nil {
|
||||||
|
r.log.Warn("metadata chain exhausted, using heuristic fallback",
|
||||||
|
slog.Int("targets", len(r.chain)),
|
||||||
|
slog.String("error", errors.Join(errs...).Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return compat.HeuristicMetadataFromInput(input), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summarize walks the chain; unlike metadata, there is no heuristic fallback —
|
||||||
|
// returns the joined error when everything fails.
|
||||||
|
func (r *MetadataRunner) Summarize(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
||||||
|
var errs []error
|
||||||
|
for _, target := range r.chain {
|
||||||
|
if r.health.skip(target) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client, err := r.registry.Client(target.Provider)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out, err := client.SummarizeWith(ctx, compat.SummarizeOptions{
|
||||||
|
Model: target.Model,
|
||||||
|
Temperature: r.opts.temperature,
|
||||||
|
}, systemPrompt, userPrompt)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
r.classify(target, err)
|
||||||
|
r.logFailure("summarize", target, err)
|
||||||
|
errs = append(errs, fmt.Errorf("%s/%s: %w", target.Provider, target.Model, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.health.markHealthy(target)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("all summarize targets failed: %w", errors.Join(errs...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EmbeddingRunner) classify(target config.RoleTarget, err error) {
|
||||||
|
switch {
|
||||||
|
case compat.IsPermanentModelError(err):
|
||||||
|
r.health.markPermanent(target)
|
||||||
|
default:
|
||||||
|
r.health.markTransient(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MetadataRunner) classify(target config.RoleTarget, err error) {
|
||||||
|
switch {
|
||||||
|
case compat.IsPermanentModelError(err):
|
||||||
|
r.health.markPermanent(target)
|
||||||
|
case errors.Is(err, compat.ErrEmptyResponse):
|
||||||
|
r.health.markEmpty(target)
|
||||||
|
default:
|
||||||
|
r.health.markTransient(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EmbeddingRunner) logFailure(role string, target config.RoleTarget, err error) {
|
||||||
|
if r.log == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.log.Warn("ai target failed",
|
||||||
|
slog.String("role", role),
|
||||||
|
slog.String("provider", target.Provider),
|
||||||
|
slog.String("model", target.Model),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MetadataRunner) logFailure(role string, target config.RoleTarget, err error) {
|
||||||
|
if r.log == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.log.Warn("ai target failed",
|
||||||
|
slog.String("role", role),
|
||||||
|
slog.String("provider", target.Provider),
|
||||||
|
slog.String("model", target.Model),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthTracker records per-(provider, model) failure state. skip returns true
|
||||||
|
// when a target is still inside its cooldown window; the caller then tries the
|
||||||
|
// next target in the chain.
|
||||||
|
type healthTracker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
states map[config.RoleTarget]*healthState
|
||||||
|
}
|
||||||
|
|
||||||
|
type healthState struct {
|
||||||
|
unhealthyUntil time.Time
|
||||||
|
emptyCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHealthTracker() *healthTracker {
|
||||||
|
return &healthTracker{states: map[config.RoleTarget]*healthState{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *healthTracker) skip(target config.RoleTarget) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
s, ok := h.states[target]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().Before(s.unhealthyUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *healthTracker) markTransient(target config.RoleTarget) {
|
||||||
|
h.setCooldown(target, transientCooldown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *healthTracker) markPermanent(target config.RoleTarget) {
|
||||||
|
h.setCooldown(target, permanentCooldown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *healthTracker) markEmpty(target config.RoleTarget) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
s := h.states[target]
|
||||||
|
if s == nil {
|
||||||
|
s = &healthState{}
|
||||||
|
h.states[target] = s
|
||||||
|
}
|
||||||
|
s.emptyCount++
|
||||||
|
if s.emptyCount >= emptyResponseThreshold {
|
||||||
|
s.unhealthyUntil = time.Now().Add(emptyResponseCooldown)
|
||||||
|
s.emptyCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *healthTracker) markHealthy(target config.RoleTarget) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if s, ok := h.states[target]; ok {
|
||||||
|
s.unhealthyUntil = time.Time{}
|
||||||
|
s.emptyCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *healthTracker) setCooldown(target config.RoleTarget, d time.Duration) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
s := h.states[target]
|
||||||
|
if s == nil {
|
||||||
|
s = &healthState{}
|
||||||
|
h.states[target] = s
|
||||||
|
}
|
||||||
|
s.unhealthyUntil = time.Now().Add(d)
|
||||||
|
s.emptyCount = 0
|
||||||
|
}
|
||||||
139
internal/ai/runner_test.go
Normal file
139
internal/ai/runner_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmbeddingRunnerFallsBackAndSkipsUnhealthyPrimary(t *testing.T) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
primaryCalls int
|
||||||
|
)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/embeddings" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch req.Model {
|
||||||
|
case "embed-primary":
|
||||||
|
mu.Lock()
|
||||||
|
primaryCalls++
|
||||||
|
mu.Unlock()
|
||||||
|
http.Error(w, "upstream down", http.StatusBadGateway)
|
||||||
|
case "embed-fallback":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"data": []map[string]any{{"embedding": []float32{0.1, 0.2, 0.3}}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.Error(w, "unknown model", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
reg, err := NewRegistry(map[string]config.ProviderConfig{
|
||||||
|
"p1": {Type: "litellm", BaseURL: srv.URL, APIKey: "k1"},
|
||||||
|
"p2": {Type: "litellm", BaseURL: srv.URL, APIKey: "k2"},
|
||||||
|
}, srv.Client(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runner, err := NewEmbeddingRunner(reg, []config.RoleTarget{
|
||||||
|
{Provider: "p1", Model: "embed-primary"},
|
||||||
|
{Provider: "p2", Model: "embed-fallback"},
|
||||||
|
}, 3, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEmbeddingRunner() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := runner.Embed(context.Background(), "hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Embed() first call error = %v", err)
|
||||||
|
}
|
||||||
|
if res.Provider != "p2" || res.Model != "embed-fallback" {
|
||||||
|
t.Fatalf("Embed() first call target = %s/%s, want p2/embed-fallback", res.Provider, res.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = runner.Embed(context.Background(), "hello again")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Embed() second call error = %v", err)
|
||||||
|
}
|
||||||
|
if res.Provider != "p2" || res.Model != "embed-fallback" {
|
||||||
|
t.Fatalf("Embed() second call target = %s/%s, want p2/embed-fallback", res.Provider, res.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
calls := primaryCalls
|
||||||
|
mu.Unlock()
|
||||||
|
if calls != 3 {
|
||||||
|
t.Fatalf("primary calls = %d, want 3 (first request retries 3x; second call should skip unhealthy primary)", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataRunnerSummarizeFallsBack(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/chat/completions" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch req.Model {
|
||||||
|
case "sum-primary":
|
||||||
|
http.Error(w, "provider error", http.StatusBadGateway)
|
||||||
|
case "sum-fallback":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"message": map[string]any{"role": "assistant", "content": "fallback summary"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.Error(w, "unknown model", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
reg, err := NewRegistry(map[string]config.ProviderConfig{
|
||||||
|
"p1": {Type: "litellm", BaseURL: srv.URL, APIKey: "k1"},
|
||||||
|
"p2": {Type: "litellm", BaseURL: srv.URL, APIKey: "k2"},
|
||||||
|
}, srv.Client(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runner, err := NewMetadataRunner(reg, []config.RoleTarget{
|
||||||
|
{Provider: "p1", Model: "sum-primary"},
|
||||||
|
{Provider: "p2", Model: "sum-fallback"},
|
||||||
|
}, 0.1, false, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewMetadataRunner() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := runner.Summarize(context.Background(), "system", "user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Summarize() error = %v", err)
|
||||||
|
}
|
||||||
|
if summary != "fallback summary" {
|
||||||
|
t.Fatalf("summary = %q, want %q", summary, "fallback summary")
|
||||||
|
}
|
||||||
|
}
|
||||||
79
internal/app/admin_actions.go
Normal file
79
internal/app/admin_actions.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminActions struct {
|
||||||
|
backfill *tools.BackfillTool
|
||||||
|
retry *tools.EnrichmentRetryer
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdminActions(backfill *tools.BackfillTool, retry *tools.EnrichmentRetryer, logger *slog.Logger) *adminActions {
|
||||||
|
return &adminActions{
|
||||||
|
backfill: backfill,
|
||||||
|
retry: retry,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminActions) backfillHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", http.MethodPost)
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var in tools.BackfillInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, out, err := a.backfill.Handle(r.Context(), nil, in)
|
||||||
|
if err != nil {
|
||||||
|
if a.logger != nil {
|
||||||
|
a.logger.Warn("admin backfill failed", slog.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(out)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adminActions) retryMetadataHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", http.MethodPost)
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var in tools.RetryEnrichmentInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, out, err := a.retry.Handle(r.Context(), nil, in)
|
||||||
|
if err != nil {
|
||||||
|
if a.logger != nil {
|
||||||
|
a.logger.Warn("admin metadata retry failed", slog.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(out)
|
||||||
|
})
|
||||||
|
}
|
||||||
268
internal/app/admin_legacy.go.disabled
Normal file
268
internal/app/admin_legacy.go.disabled
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package app
|
||||||
|
// Legacy admin handlers retired in favor of ResolveSpec-backed routes.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminHandlers struct {
|
||||||
|
db *store.DB
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdminHandlers(db *store.DB, logger *slog.Logger) *adminHandlers {
|
||||||
|
return &adminHandlers{db: db, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) register(mux *http.ServeMux, middleware func(http.Handler) http.Handler) {
|
||||||
|
handle := func(pattern string, fn http.HandlerFunc) {
|
||||||
|
mux.Handle(pattern, middleware(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
handle("GET /api/admin/projects", h.listProjects)
|
||||||
|
handle("POST /api/admin/projects", h.createProject)
|
||||||
|
handle("GET /api/admin/thoughts", h.listThoughts)
|
||||||
|
handle("GET /api/admin/thoughts/{id}", h.getThought)
|
||||||
|
handle("DELETE /api/admin/thoughts/{id}", h.deleteThought)
|
||||||
|
handle("POST /api/admin/thoughts/{id}/archive", h.archiveThought)
|
||||||
|
handle("GET /api/admin/skills", h.listSkills)
|
||||||
|
handle("DELETE /api/admin/skills/{id}", h.deleteSkill)
|
||||||
|
handle("GET /api/admin/guardrails", h.listGuardrails)
|
||||||
|
handle("DELETE /api/admin/guardrails/{id}", h.deleteGuardrail)
|
||||||
|
handle("GET /api/admin/files", h.listFiles)
|
||||||
|
handle("GET /api/admin/stats", h.stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Projects ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) listProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projects, err := h.db.ListProjects(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "list projects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, projects)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) createProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(body.Name) == "" {
|
||||||
|
http.Error(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project, err := h.db.CreateProject(r.Context(), body.Name, body.Description)
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "create project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
writeJSON(w, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Thoughts ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) listThoughts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
limit := 50
|
||||||
|
if l := q.Get("limit"); l != "" {
|
||||||
|
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||||
|
limit = min(n, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.TrimSpace(q.Get("q"))
|
||||||
|
includeArchived := q.Get("include_archived") == "true"
|
||||||
|
|
||||||
|
var projectID *uuid.UUID
|
||||||
|
if pid := q.Get("project_id"); pid != "" {
|
||||||
|
if id, err := uuid.Parse(pid); err == nil {
|
||||||
|
projectID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if query != "" {
|
||||||
|
results, err := h.db.SearchThoughtsText(r.Context(), query, limit, projectID, nil)
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "search thoughts", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, results)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thoughts, err := h.db.ListThoughts(r.Context(), ext.ListFilter{
|
||||||
|
Limit: limit,
|
||||||
|
ProjectID: projectID,
|
||||||
|
IncludeArchived: includeArchived,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "list thoughts", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, thoughts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) getThought(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(w, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
thought, err := h.db.GetThought(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "get thought", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, thought)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) deleteThought(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(w, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.DeleteThought(r.Context(), id); err != nil {
|
||||||
|
h.internalError(w, "delete thought", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) archiveThought(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(w, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.ArchiveThought(r.Context(), id); err != nil {
|
||||||
|
h.internalError(w, "archive thought", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Skills ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) listSkills(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tag := r.URL.Query().Get("tag")
|
||||||
|
skills, err := h.db.ListSkills(r.Context(), tag)
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "list skills", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) deleteSkill(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(w, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.RemoveSkill(r.Context(), id); err != nil {
|
||||||
|
h.internalError(w, "delete skill", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Guardrails ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) listGuardrails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
guardrails, err := h.db.ListGuardrails(r.Context(), q.Get("tag"), q.Get("severity"))
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "list guardrails", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, guardrails)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) deleteGuardrail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(w, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.RemoveGuardrail(r.Context(), id); err != nil {
|
||||||
|
h.internalError(w, "delete guardrail", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Files ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) listFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
limit := 100
|
||||||
|
if l := q.Get("limit"); l != "" {
|
||||||
|
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||||
|
limit = min(n, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := ext.StoredFileFilter{Limit: limit}
|
||||||
|
if pid := q.Get("project_id"); pid != "" {
|
||||||
|
if id, err := uuid.Parse(pid); err == nil {
|
||||||
|
filter.ProjectID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tid := q.Get("thought_id"); tid != "" {
|
||||||
|
if id, err := uuid.Parse(tid); err == nil {
|
||||||
|
filter.ThoughtID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filter.Kind = q.Get("kind")
|
||||||
|
|
||||||
|
files, err := h.db.ListStoredFiles(r.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "list files", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stats ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) stats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stats, err := h.db.Stats(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "stats", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) internalError(w http.ResponseWriter, op string, err error) {
|
||||||
|
h.logger.Error("admin handler error", slog.String("op", op), slog.String("error", err.Error()))
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUUID(w http.ResponseWriter, s string) (uuid.UUID, bool) {
|
||||||
|
id, err := uuid.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return uuid.UUID{}, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ func Run(ctx context.Context, configPath string) error {
|
|||||||
|
|
||||||
logger.Info("loaded configuration",
|
logger.Info("loaded configuration",
|
||||||
slog.String("path", loadedFrom),
|
slog.String("path", loadedFrom),
|
||||||
slog.String("provider", cfg.AI.Provider),
|
slog.Int("config_version", cfg.Version),
|
||||||
slog.String("version", info.Version),
|
slog.String("version", info.Version),
|
||||||
slog.String("tag_name", info.TagName),
|
slog.String("tag_name", info.TagName),
|
||||||
slog.String("build_date", info.BuildDate),
|
slog.String("build_date", info.BuildDate),
|
||||||
@@ -52,11 +52,37 @@ func Run(ctx context.Context, configPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||||
provider, err := ai.NewProvider(cfg.AI, httpClient, logger)
|
registry, err := ai.NewRegistry(cfg.AI.Providers, httpClient, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foregroundEmbeddings, err := ai.NewEmbeddingRunner(registry, cfg.AI.Embeddings.Chain(), cfg.AI.Embeddings.Dimensions, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
foregroundMetadata, err := ai.NewMetadataRunner(registry, cfg.AI.Metadata.Chain(), cfg.AI.Metadata.Temperature, cfg.AI.Metadata.LogConversations, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundEmbeddings := foregroundEmbeddings
|
||||||
|
backgroundMetadata := foregroundMetadata
|
||||||
|
if cfg.AI.Background != nil {
|
||||||
|
if cfg.AI.Background.Embeddings != nil {
|
||||||
|
backgroundEmbeddings, err = ai.NewEmbeddingRunner(registry, cfg.AI.Background.Embeddings.AsTargets(), cfg.AI.Embeddings.Dimensions, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.AI.Background.Metadata != nil {
|
||||||
|
backgroundMetadata, err = ai.NewMetadataRunner(registry, cfg.AI.Background.Metadata.AsTargets(), cfg.AI.Metadata.Temperature, cfg.AI.Metadata.LogConversations, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var keyring *auth.Keyring
|
var keyring *auth.Keyring
|
||||||
var oauthRegistry *auth.OAuthRegistry
|
var oauthRegistry *auth.OAuthRegistry
|
||||||
var tokenStore *auth.TokenStore
|
var tokenStore *auth.TokenStore
|
||||||
@@ -66,23 +92,24 @@ func Run(ctx context.Context, configPath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tokenStore = auth.NewTokenStore(0)
|
||||||
if len(cfg.Auth.OAuth.Clients) > 0 {
|
if len(cfg.Auth.OAuth.Clients) > 0 {
|
||||||
oauthRegistry, err = auth.NewOAuthRegistry(cfg.Auth.OAuth.Clients)
|
oauthRegistry, err = auth.NewOAuthRegistry(cfg.Auth.OAuth.Clients)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tokenStore = auth.NewTokenStore(0)
|
|
||||||
}
|
}
|
||||||
authCodes := auth.NewAuthCodeStore()
|
authCodes := auth.NewAuthCodeStore()
|
||||||
dynClients := auth.NewDynamicClientStore()
|
dynClients := auth.NewDynamicClientStore()
|
||||||
activeProjects := session.NewActiveProjects()
|
activeProjects := session.NewActiveProjects()
|
||||||
|
|
||||||
logger.Info("database connection verified",
|
logger.Info("ai providers initialised",
|
||||||
slog.String("provider", provider.Name()),
|
slog.String("embedding_primary", foregroundEmbeddings.PrimaryProvider()+"/"+foregroundEmbeddings.PrimaryModel()),
|
||||||
|
slog.String("metadata_primary", foregroundMetadata.PrimaryProvider()+"/"+foregroundMetadata.PrimaryModel()),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cfg.Backfill.Enabled && cfg.Backfill.RunOnStartup {
|
if cfg.Backfill.Enabled && cfg.Backfill.RunOnStartup {
|
||||||
go runBackfillPass(ctx, db, provider, cfg.Backfill, logger)
|
go runBackfillPass(ctx, db, backgroundEmbeddings, cfg.Backfill, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Backfill.Enabled && cfg.Backfill.Interval > 0 {
|
if cfg.Backfill.Enabled && cfg.Backfill.Interval > 0 {
|
||||||
@@ -94,14 +121,14 @@ func Run(ctx context.Context, configPath string) error {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
runBackfillPass(ctx, db, provider, cfg.Backfill, logger)
|
runBackfillPass(ctx, db, backgroundEmbeddings, cfg.Backfill, logger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.MetadataRetry.Enabled && cfg.MetadataRetry.RunOnStartup {
|
if cfg.MetadataRetry.Enabled && cfg.MetadataRetry.RunOnStartup {
|
||||||
go runMetadataRetryPass(ctx, db, provider, cfg, activeProjects, logger)
|
go runMetadataRetryPass(ctx, db, backgroundMetadata, cfg, activeProjects, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.MetadataRetry.Enabled && cfg.MetadataRetry.Interval > 0 {
|
if cfg.MetadataRetry.Enabled && cfg.MetadataRetry.Interval > 0 {
|
||||||
@@ -113,13 +140,13 @@ func Run(ctx context.Context, configPath string) error {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
runMetadataRetryPass(ctx, db, provider, cfg, activeProjects, logger)
|
runMetadataRetryPass(ctx, db, backgroundMetadata, cfg, activeProjects, logger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
handler, err := routes(logger, cfg, info, db, provider, keyring, oauthRegistry, tokenStore, authCodes, dynClients, activeProjects)
|
handler, err := routes(logger, cfg, info, db, foregroundEmbeddings, foregroundMetadata, backgroundEmbeddings, backgroundMetadata, keyring, oauthRegistry, tokenStore, authCodes, dynClients, activeProjects)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -156,58 +183,71 @@ func Run(ctx context.Context, configPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, provider ai.Provider, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) {
|
func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, bgEmbeddings *ai.EmbeddingRunner, bgMetadata *ai.MetadataRunner, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, logger)
|
accessTracker := auth.NewAccessTracker()
|
||||||
|
oauthEnabled := oauthRegistry != nil
|
||||||
|
authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, accessTracker, logger)
|
||||||
filesTool := tools.NewFilesTool(db, activeProjects)
|
filesTool := tools.NewFilesTool(db, activeProjects)
|
||||||
metadataRetryer := tools.NewMetadataRetryer(context.Background(), db, provider, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
||||||
|
backfillTool := tools.NewBackfillTool(db, bgEmbeddings, activeProjects, logger)
|
||||||
|
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
|
||||||
|
|
||||||
toolSet := mcpserver.ToolSet{
|
toolSet := mcpserver.ToolSet{
|
||||||
Capture: tools.NewCaptureTool(db, provider, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, metadataRetryer, logger),
|
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
|
||||||
Search: tools.NewSearchTool(db, provider, cfg.Search, activeProjects),
|
Search: tools.NewSearchTool(db, embeddings, cfg.Search, activeProjects),
|
||||||
List: tools.NewListTool(db, cfg.Search, activeProjects),
|
List: tools.NewListTool(db, cfg.Search, activeProjects),
|
||||||
Stats: tools.NewStatsTool(db),
|
Stats: tools.NewStatsTool(db),
|
||||||
Get: tools.NewGetTool(db),
|
Get: tools.NewGetTool(db),
|
||||||
Update: tools.NewUpdateTool(db, provider, cfg.Capture, logger),
|
Update: tools.NewUpdateTool(db, embeddings, metadata, cfg.Capture, logger),
|
||||||
Delete: tools.NewDeleteTool(db),
|
Delete: tools.NewDeleteTool(db),
|
||||||
Archive: tools.NewArchiveTool(db),
|
Archive: tools.NewArchiveTool(db),
|
||||||
Projects: tools.NewProjectsTool(db, activeProjects),
|
Projects: tools.NewProjectsTool(db, activeProjects),
|
||||||
Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
|
Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
|
||||||
Context: tools.NewContextTool(db, provider, cfg.Search, activeProjects),
|
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
|
||||||
Recall: tools.NewRecallTool(db, provider, cfg.Search, activeProjects),
|
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
|
||||||
Summarize: tools.NewSummarizeTool(db, provider, cfg.Search, activeProjects),
|
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
|
||||||
Links: tools.NewLinksTool(db, provider, cfg.Search),
|
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),
|
||||||
|
Links: tools.NewLinksTool(db, embeddings, cfg.Search),
|
||||||
Files: filesTool,
|
Files: filesTool,
|
||||||
Backfill: tools.NewBackfillTool(db, provider, activeProjects, logger),
|
Backfill: backfillTool,
|
||||||
Reparse: tools.NewReparseMetadataTool(db, provider, cfg.Capture, activeProjects, logger),
|
Reparse: tools.NewReparseMetadataTool(db, bgMetadata, cfg.Capture, activeProjects, logger),
|
||||||
RetryMetadata: tools.NewRetryMetadataTool(metadataRetryer),
|
RetryMetadata: tools.NewRetryEnrichmentTool(enrichmentRetryer),
|
||||||
Household: tools.NewHouseholdTool(db),
|
|
||||||
Maintenance: tools.NewMaintenanceTool(db),
|
Maintenance: tools.NewMaintenanceTool(db),
|
||||||
Calendar: tools.NewCalendarTool(db),
|
|
||||||
Meals: tools.NewMealsTool(db),
|
|
||||||
CRM: tools.NewCRMTool(db),
|
|
||||||
Skills: tools.NewSkillsTool(db, activeProjects),
|
Skills: tools.NewSkillsTool(db, activeProjects),
|
||||||
ChatHistory: tools.NewChatHistoryTool(db, activeProjects),
|
ChatHistory: tools.NewChatHistoryTool(db, activeProjects),
|
||||||
|
Describe: tools.NewDescribeTool(db, mcpserver.BuildToolCatalog()),
|
||||||
}
|
}
|
||||||
|
|
||||||
mcpHandler, err := mcpserver.New(cfg.MCP, logger, toolSet, activeProjects.Clear)
|
mcpHandlers, err := mcpserver.NewHandlers(cfg.MCP, logger, toolSet, activeProjects.Clear)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build mcp handler: %w", err)
|
return nil, fmt.Errorf("build mcp handler: %w", err)
|
||||||
}
|
}
|
||||||
mux.Handle(cfg.MCP.Path, authMiddleware(mcpHandler))
|
mux.Handle(cfg.MCP.Path, authMiddleware(mcpHandlers.StreamableHTTP))
|
||||||
|
if mcpHandlers.SSE != nil {
|
||||||
|
mux.Handle(cfg.MCP.SSEPath, authMiddleware(mcpHandlers.SSE))
|
||||||
|
logger.Info("SSE transport enabled", slog.String("sse_path", cfg.MCP.SSEPath))
|
||||||
|
}
|
||||||
|
if err := registerResolveSpecAdminRoutes(mux, db, authMiddleware, logger); err != nil {
|
||||||
|
return nil, fmt.Errorf("setup resolvespec admin routes: %w", err)
|
||||||
|
}
|
||||||
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
|
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
|
||||||
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
|
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
|
||||||
if oauthRegistry != nil && tokenStore != nil {
|
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
|
||||||
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
|
mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
|
||||||
mux.HandleFunc("/oauth-authorization-server", oauthMetadataHandler())
|
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
||||||
mux.HandleFunc("/oauth/register", oauthRegisterHandler(dynClients, logger))
|
mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
|
||||||
mux.HandleFunc("/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
mux.Handle("/api/admin/actions/backfill", authMiddleware(adminActions.backfillHandler()))
|
||||||
mux.HandleFunc("/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
mux.Handle("/api/admin/actions/retry-metadata", authMiddleware(adminActions.retryMetadataHandler()))
|
||||||
mux.HandleFunc("/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
|
|
||||||
}
|
|
||||||
mux.HandleFunc("/favicon.ico", serveFavicon)
|
mux.HandleFunc("/favicon.ico", serveFavicon)
|
||||||
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
||||||
|
mux.HandleFunc("/images/icon.png", serveIcon)
|
||||||
mux.HandleFunc("/llm", serveLLMInstructions)
|
mux.HandleFunc("/llm", serveLLMInstructions)
|
||||||
|
mux.HandleFunc("/llms.txt", serveLLMSTXT)
|
||||||
|
mux.HandleFunc("/.well-known/llms.txt", serveLLMSTXT)
|
||||||
|
mux.HandleFunc("/robots.txt", serveRobotsTXT)
|
||||||
|
mux.HandleFunc("/api/status", statusAPIHandler(info, accessTracker, oauthEnabled))
|
||||||
|
mux.HandleFunc("/status", statusAPIHandler(info, accessTracker, oauthEnabled))
|
||||||
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -225,59 +265,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
_, _ = w.Write([]byte("ready"))
|
_, _ = w.Write([]byte("ready"))
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", homeHandler(info, accessTracker, oauthEnabled))
|
||||||
if r.URL.Path != "/" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
||||||
w.Header().Set("Allow", "GET, HEAD")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const homePage = `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>AMCS</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f7fb; color: #172033; }
|
|
||||||
main { max-width: 860px; margin: 48px auto; background: #fff; border-radius: 12px; box-shadow: 0 10px 28px rgba(23, 32, 51, 0.12); overflow: hidden; }
|
|
||||||
.content { padding: 28px; }
|
|
||||||
h1 { margin: 0 0 12px 0; font-size: 2rem; }
|
|
||||||
p { margin: 0; line-height: 1.5; color: #334155; }
|
|
||||||
.actions { margin-top: 18px; }
|
|
||||||
.link { display: inline-block; padding: 10px 14px; border-radius: 8px; background: #172033; color: #fff; text-decoration: none; font-weight: 600; }
|
|
||||||
.link:hover { background: #0f172a; }
|
|
||||||
img { display: block; width: 100%; height: auto; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<img src="/images/project.jpg" alt="Avelon Memory Crystal project image">
|
|
||||||
<div class="content">
|
|
||||||
<h1>Avelon Memory Crystal Server (AMCS)</h1>
|
|
||||||
<p>AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.</p>
|
|
||||||
<div class="actions">
|
|
||||||
<a class="link" href="/llm">LLM Instructions</a>
|
|
||||||
<a class="link" href="/oauth-authorization-server">OAuth Authorization Server</a>
|
|
||||||
<a class="link" href="/healthz">Health Check</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = w.Write([]byte(homePage))
|
|
||||||
})
|
|
||||||
|
|
||||||
return observability.Chain(
|
return observability.Chain(
|
||||||
mux,
|
mux,
|
||||||
@@ -288,8 +276,8 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMetadataRetryPass(ctx context.Context, db *store.DB, provider ai.Provider, cfg *config.Config, activeProjects *session.ActiveProjects, logger *slog.Logger) {
|
func runMetadataRetryPass(ctx context.Context, db *store.DB, metadataRunner *ai.MetadataRunner, cfg *config.Config, activeProjects *session.ActiveProjects, logger *slog.Logger) {
|
||||||
retryer := tools.NewMetadataRetryer(ctx, db, provider, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
retryer := tools.NewMetadataRetryer(ctx, db, metadataRunner, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
||||||
_, out, err := retryer.Handle(ctx, nil, tools.RetryMetadataInput{
|
_, out, err := retryer.Handle(ctx, nil, tools.RetryMetadataInput{
|
||||||
Limit: cfg.MetadataRetry.MaxPerRun,
|
Limit: cfg.MetadataRetry.MaxPerRun,
|
||||||
IncludeArchived: cfg.MetadataRetry.IncludeArchived,
|
IncludeArchived: cfg.MetadataRetry.IncludeArchived,
|
||||||
@@ -307,8 +295,8 @@ func runMetadataRetryPass(ctx context.Context, db *store.DB, provider ai.Provide
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runBackfillPass(ctx context.Context, db *store.DB, provider ai.Provider, cfg config.BackfillConfig, logger *slog.Logger) {
|
func runBackfillPass(ctx context.Context, db *store.DB, embeddings *ai.EmbeddingRunner, cfg config.BackfillConfig, logger *slog.Logger) {
|
||||||
backfiller := tools.NewBackfillTool(db, provider, nil, logger)
|
backfiller := tools.NewBackfillTool(db, embeddings, nil, logger)
|
||||||
_, out, err := backfiller.Handle(ctx, nil, tools.BackfillInput{
|
_, out, err := backfiller.Handle(ctx, nil, tools.BackfillInput{
|
||||||
Limit: cfg.MaxPerRun,
|
Limit: cfg.MaxPerRun,
|
||||||
IncludeArchived: cfg.IncludeArchived,
|
IncludeArchived: cfg.IncludeArchived,
|
||||||
@@ -342,3 +330,26 @@ func serveHomeImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
_, _ = w.Write(homeImage)
|
_, _ = w.Write(homeImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveIcon(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if iconImage == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = w.Write(iconImage)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
amcsllm "git.warky.dev/wdevs/amcs/llm"
|
amcsllm "git.warky.dev/wdevs/amcs/llm"
|
||||||
)
|
)
|
||||||
@@ -20,3 +22,74 @@ func serveLLMInstructions(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
_, _ = w.Write(amcsllm.MemoryInstructions)
|
_, _ = w.Write(amcsllm.MemoryInstructions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveRobotsTXT(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/robots.txt" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := fmt.Sprintf("User-agent: *\nAllow: /\n\n# LLM-friendly docs\nLLM: %s/llm\nLLMS: %s/llms.txt\n", requestBaseURL(r), requestBaseURL(r))
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveLLMSTXT(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/llms.txt" && r.URL.Path != "/.well-known/llms.txt" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base := requestBaseURL(r)
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
"# AMCS\n\n> A memory server for AI assistants (MCP tools, semantic retrieval, and structured project memory).\n\n## Endpoints\n- %s/llm\n- %s/status\n- %s/mcp\n- %s/.well-known/oauth-authorization-server\n",
|
||||||
|
base,
|
||||||
|
base,
|
||||||
|
base,
|
||||||
|
base,
|
||||||
|
)
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestBaseURL(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r != nil && r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if r != nil {
|
||||||
|
if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
host := "localhost"
|
||||||
|
if r != nil {
|
||||||
|
if v := strings.TrimSpace(r.Host); v != "" {
|
||||||
|
host = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
amcsllm "git.warky.dev/wdevs/amcs/llm"
|
amcsllm "git.warky.dev/wdevs/amcs/llm"
|
||||||
@@ -29,3 +30,70 @@ func TestServeLLMInstructions(t *testing.T) {
|
|||||||
t.Fatalf("body = %q, want embedded instructions", body)
|
t.Fatalf("body = %q, want embedded instructions", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServeRobotsTXT(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/robots.txt", nil)
|
||||||
|
req.Host = "amcs.example.com"
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
serveRobotsTXT(rec, req)
|
||||||
|
|
||||||
|
res := rec.Result()
|
||||||
|
defer func() {
|
||||||
|
_ = res.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := res.Header.Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
||||||
|
t.Fatalf("content-type = %q, want %q", got, "text/plain; charset=utf-8")
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "LLM: https://amcs.example.com/llm") {
|
||||||
|
t.Fatalf("body = %q, want LLM link", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "LLMS: https://amcs.example.com/llms.txt") {
|
||||||
|
t.Fatalf("body = %q, want LLMS link", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeLLMSTXT(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/llms.txt", nil)
|
||||||
|
req.Host = "amcs.example.com"
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
serveLLMSTXT(rec, req)
|
||||||
|
|
||||||
|
res := rec.Result()
|
||||||
|
defer func() {
|
||||||
|
_ = res.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := res.Header.Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
||||||
|
t.Fatalf("content-type = %q, want %q", got, "text/plain; charset=utf-8")
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "https://amcs.example.com/llm") {
|
||||||
|
t.Fatalf("body = %q, want /llm link", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "https://amcs.example.com/.well-known/oauth-authorization-server") {
|
||||||
|
t.Fatalf("body = %q, want oauth discovery link", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeLLMSTXTWellKnownPath(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/.well-known/llms.txt", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
serveLLMSTXT(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/auth"
|
"git.warky.dev/wdevs/amcs/internal/auth"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/requestip"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- JSON types ---
|
// --- JSON types ---
|
||||||
@@ -66,9 +67,9 @@ func oauthMetadataHandler() http.HandlerFunc {
|
|||||||
base := serverBaseURL(r)
|
base := serverBaseURL(r)
|
||||||
meta := oauthServerMetadata{
|
meta := oauthServerMetadata{
|
||||||
Issuer: base,
|
Issuer: base,
|
||||||
AuthorizationEndpoint: base + "/authorize",
|
AuthorizationEndpoint: base + "/api/oauth/authorize",
|
||||||
TokenEndpoint: base + "/oauth/token",
|
TokenEndpoint: base + "/api/oauth/token",
|
||||||
RegistrationEndpoint: base + "/oauth/register",
|
RegistrationEndpoint: base + "/api/oauth/register",
|
||||||
ScopesSupported: []string{"mcp"},
|
ScopesSupported: []string{"mcp"},
|
||||||
ResponseTypesSupported: []string{"code"},
|
ResponseTypesSupported: []string{"code"},
|
||||||
GrantTypesSupported: []string{"authorization_code", "client_credentials"},
|
GrantTypesSupported: []string{"authorization_code", "client_credentials"},
|
||||||
@@ -243,6 +244,10 @@ func oauthTokenHandler(oauthRegistry *auth.OAuthRegistry, tokenStore *auth.Token
|
|||||||
|
|
||||||
switch r.FormValue("grant_type") {
|
switch r.FormValue("grant_type") {
|
||||||
case "client_credentials":
|
case "client_credentials":
|
||||||
|
if oauthRegistry == nil {
|
||||||
|
writeTokenError(w, "unsupported_grant_type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
handleClientCredentials(w, r, oauthRegistry, tokenStore, log)
|
handleClientCredentials(w, r, oauthRegistry, tokenStore, log)
|
||||||
case "authorization_code":
|
case "authorization_code":
|
||||||
handleAuthorizationCode(w, r, authCodes, tokenStore, log)
|
handleAuthorizationCode(w, r, authCodes, tokenStore, log)
|
||||||
@@ -261,7 +266,7 @@ func handleClientCredentials(w http.ResponseWriter, r *http.Request, oauthRegist
|
|||||||
}
|
}
|
||||||
keyID, ok := oauthRegistry.Lookup(clientID, clientSecret)
|
keyID, ok := oauthRegistry.Lookup(clientID, clientSecret)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Warn("oauth token: invalid client credentials", slog.String("remote_addr", r.RemoteAddr))
|
log.Warn("oauth token: invalid client credentials", slog.String("remote_addr", requestip.FromRequest(r)))
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="oauth"`)
|
w.Header().Set("WWW-Authenticate", `Basic realm="oauth"`)
|
||||||
writeTokenError(w, "invalid_client", http.StatusUnauthorized)
|
writeTokenError(w, "invalid_client", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -290,7 +295,7 @@ func handleAuthorizationCode(w http.ResponseWriter, r *http.Request, authCodes *
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !verifyPKCE(codeVerifier, entry.CodeChallenge, entry.CodeChallengeMethod) {
|
if !verifyPKCE(codeVerifier, entry.CodeChallenge, entry.CodeChallengeMethod) {
|
||||||
log.Warn("oauth token: PKCE verification failed", slog.String("remote_addr", r.RemoteAddr))
|
log.Warn("oauth token: PKCE verification failed", slog.String("remote_addr", requestip.FromRequest(r)))
|
||||||
writeTokenError(w, "invalid_grant", http.StatusBadRequest)
|
writeTokenError(w, "invalid_grant", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -333,7 +338,7 @@ button{padding:.5rem 1.2rem;margin-right:.5rem;cursor:pointer;font-size:1rem}
|
|||||||
<body>
|
<body>
|
||||||
<h2>Authorize Access</h2>
|
<h2>Authorize Access</h2>
|
||||||
<p><strong>%s</strong> is requesting access to this AMCS server.</p>
|
<p><strong>%s</strong> is requesting access to this AMCS server.</p>
|
||||||
<form method=POST action=/oauth/authorize>
|
<form method=POST action=/api/oauth/authorize>
|
||||||
<input type=hidden name=client_id value="%s">
|
<input type=hidden name=client_id value="%s">
|
||||||
<input type=hidden name=redirect_uri value="%s">
|
<input type=hidden name=redirect_uri value="%s">
|
||||||
<input type=hidden name=state value="%s">
|
<input type=hidden name=state value="%s">
|
||||||
|
|||||||
128
internal/app/resolvespec_admin.go
Normal file
128
internal/app/resolvespec_admin.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||||
|
"github.com/uptrace/bunrouter"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerResolveSpecAdminRoutes(mux *http.ServeMux, db *store.DB, middleware func(http.Handler) http.Handler, logger *slog.Logger) error {
|
||||||
|
rs := resolvespec.NewHandlerWithBun(db.Bun())
|
||||||
|
registerResolveSpecGuards(rs)
|
||||||
|
for _, model := range resolveSpecModels() {
|
||||||
|
if err := rs.RegisterModel(model.schema, model.entity, model.model); err != nil {
|
||||||
|
return fmt.Errorf("register resolvespec model %s.%s: %w", model.schema, model.entity, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rsRouter := bunrouter.New()
|
||||||
|
resolvespec.SetupBunRouterRoutes(rsRouter, rs, nil)
|
||||||
|
|
||||||
|
rsMount := http.StripPrefix("/api/rs", rsRouter)
|
||||||
|
protectedRSMount := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
rsMount.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
middleware(rsMount).ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.Handle("/api/rs/", protectedRSMount)
|
||||||
|
mux.Handle("/api/rs", http.RedirectHandler("/api/rs/openapi", http.StatusTemporaryRedirect))
|
||||||
|
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("resolvespec admin api enabled",
|
||||||
|
slog.String("prefix", "/api/rs"),
|
||||||
|
slog.Int("models", len(resolveSpecModels())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerResolveSpecGuards(rs *resolvespec.Handler) {
|
||||||
|
mutableByEntity := map[string]map[string]struct{}{
|
||||||
|
"projects": {
|
||||||
|
"create": {},
|
||||||
|
},
|
||||||
|
"thoughts": {
|
||||||
|
"update": {},
|
||||||
|
"delete": {},
|
||||||
|
},
|
||||||
|
"agent_skills": {
|
||||||
|
"delete": {},
|
||||||
|
},
|
||||||
|
"agent_guardrails": {
|
||||||
|
"delete": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rs.Hooks().Register(resolvespec.BeforeHandle, func(hookCtx *resolvespec.HookContext) error {
|
||||||
|
switch hookCtx.Operation {
|
||||||
|
case "read", "meta":
|
||||||
|
return nil
|
||||||
|
case "create", "update", "delete":
|
||||||
|
allowedOps, ok := mutableByEntity[hookCtx.Entity]
|
||||||
|
if !ok {
|
||||||
|
hookCtx.Abort = true
|
||||||
|
hookCtx.AbortCode = http.StatusForbidden
|
||||||
|
hookCtx.AbortMessage = fmt.Sprintf("operation %q is not allowed for %s.%s", hookCtx.Operation, hookCtx.Schema, hookCtx.Entity)
|
||||||
|
return fmt.Errorf("forbidden operation")
|
||||||
|
}
|
||||||
|
if _, ok := allowedOps[hookCtx.Operation]; !ok {
|
||||||
|
hookCtx.Abort = true
|
||||||
|
hookCtx.AbortCode = http.StatusForbidden
|
||||||
|
hookCtx.AbortMessage = fmt.Sprintf("operation %q is not allowed for %s.%s", hookCtx.Operation, hookCtx.Schema, hookCtx.Entity)
|
||||||
|
return fmt.Errorf("forbidden operation")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
hookCtx.Abort = true
|
||||||
|
hookCtx.AbortCode = http.StatusBadRequest
|
||||||
|
hookCtx.AbortMessage = fmt.Sprintf("unsupported operation %q", hookCtx.Operation)
|
||||||
|
return fmt.Errorf("unsupported operation")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolveSpecModel struct {
|
||||||
|
schema string
|
||||||
|
entity string
|
||||||
|
model any
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSpecModels() []resolveSpecModel {
|
||||||
|
return []resolveSpecModel{
|
||||||
|
{schema: "public", entity: "activities", model: generatedmodels.ModelPublicActivities{}},
|
||||||
|
{schema: "public", entity: "agent_guardrails", model: generatedmodels.ModelPublicAgentGuardrails{}},
|
||||||
|
{schema: "public", entity: "agent_skills", model: generatedmodels.ModelPublicAgentSkills{}},
|
||||||
|
{schema: "public", entity: "chat_histories", model: generatedmodels.ModelPublicChatHistories{}},
|
||||||
|
{schema: "public", entity: "contact_interactions", model: generatedmodels.ModelPublicContactInteractions{}},
|
||||||
|
{schema: "public", entity: "embeddings", model: generatedmodels.ModelPublicEmbeddings{}},
|
||||||
|
{schema: "public", entity: "family_members", model: generatedmodels.ModelPublicFamilyMembers{}},
|
||||||
|
{schema: "public", entity: "household_items", model: generatedmodels.ModelPublicHouseholdItems{}},
|
||||||
|
{schema: "public", entity: "household_vendors", model: generatedmodels.ModelPublicHouseholdVendors{}},
|
||||||
|
{schema: "public", entity: "important_dates", model: generatedmodels.ModelPublicImportantDates{}},
|
||||||
|
{schema: "public", entity: "learnings", model: generatedmodels.ModelPublicLearnings{}},
|
||||||
|
{schema: "public", entity: "maintenance_logs", model: generatedmodels.ModelPublicMaintenanceLogs{}},
|
||||||
|
{schema: "public", entity: "maintenance_tasks", model: generatedmodels.ModelPublicMaintenanceTasks{}},
|
||||||
|
{schema: "public", entity: "meal_plans", model: generatedmodels.ModelPublicMealPlans{}},
|
||||||
|
{schema: "public", entity: "opportunities", model: generatedmodels.ModelPublicOpportunities{}},
|
||||||
|
{schema: "public", entity: "professional_contacts", model: generatedmodels.ModelPublicProfessionalContacts{}},
|
||||||
|
{schema: "public", entity: "project_guardrails", model: generatedmodels.ModelPublicProjectGuardrails{}},
|
||||||
|
{schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}},
|
||||||
|
{schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}},
|
||||||
|
{schema: "public", entity: "recipes", model: generatedmodels.ModelPublicRecipes{}},
|
||||||
|
{schema: "public", entity: "shopping_lists", model: generatedmodels.ModelPublicShoppingLists{}},
|
||||||
|
{schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}},
|
||||||
|
{schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}},
|
||||||
|
{schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}},
|
||||||
|
{schema: "public", entity: "tool_annotations", model: generatedmodels.ModelPublicToolAnnotations{}},
|
||||||
|
}
|
||||||
|
}
|
||||||
172
internal/app/resolvespec_admin_test.go
Normal file
172
internal/app/resolvespec_admin_test.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/auth"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveSpecAuthRequiresValidCredentials(t *testing.T) {
|
||||||
|
keyring, err := auth.NewKeyring([]config.APIKey{{ID: "operator", Value: "secret"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
protected := auth.Middleware(config.AuthConfig{}, keyring, nil, nil, nil, logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/rs/public/projects" {
|
||||||
|
t.Fatalf("path = %q, want /api/rs/public/projects", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
t.Run("missing credentials are rejected", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/rs/public/projects", strings.NewReader(`{"operation":"read"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
protected.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid API key is accepted", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/rs/public/projects", strings.NewReader(`{"operation":"read"}`))
|
||||||
|
req.Header.Set("x-brain-key", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
protected.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSpecGuardAllowsSupportedMutations(t *testing.T) {
|
||||||
|
rs := resolvespec.NewHandler(nil, nil)
|
||||||
|
registerResolveSpecGuards(rs)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
entity string
|
||||||
|
operation string
|
||||||
|
}{
|
||||||
|
{name: "learnings read", entity: "learnings", operation: "read"},
|
||||||
|
{name: "projects create", entity: "projects", operation: "create"},
|
||||||
|
{name: "thoughts update", entity: "thoughts", operation: "update"},
|
||||||
|
{name: "thoughts delete", entity: "thoughts", operation: "delete"},
|
||||||
|
{name: "agent_skills delete", entity: "agent_skills", operation: "delete"},
|
||||||
|
{name: "agent_guardrails delete", entity: "agent_guardrails", operation: "delete"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
hookCtx := &resolvespec.HookContext{
|
||||||
|
Schema: "public",
|
||||||
|
Entity: tc.entity,
|
||||||
|
Operation: tc.operation,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := rs.Hooks().Execute(resolvespec.BeforeHandle, hookCtx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if hookCtx.Abort {
|
||||||
|
t.Fatalf("Abort = true, want false (code=%d message=%q)", hookCtx.AbortCode, hookCtx.AbortMessage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSpecGuardBlocksUnsupportedMutations(t *testing.T) {
|
||||||
|
rs := resolvespec.NewHandler(nil, nil)
|
||||||
|
registerResolveSpecGuards(rs)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
entity string
|
||||||
|
operation string
|
||||||
|
wantCode int
|
||||||
|
wantMessageIn string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "create not allowed on thoughts",
|
||||||
|
entity: "thoughts",
|
||||||
|
operation: "create",
|
||||||
|
wantCode: http.StatusForbidden,
|
||||||
|
wantMessageIn: `operation "create" is not allowed for public.thoughts`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete not allowed on projects",
|
||||||
|
entity: "projects",
|
||||||
|
operation: "delete",
|
||||||
|
wantCode: http.StatusForbidden,
|
||||||
|
wantMessageIn: `operation "delete" is not allowed for public.projects`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mutations blocked for non-allowlisted entity",
|
||||||
|
entity: "stored_files",
|
||||||
|
operation: "delete",
|
||||||
|
wantCode: http.StatusForbidden,
|
||||||
|
wantMessageIn: `operation "delete" is not allowed for public.stored_files`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mutations blocked for learnings",
|
||||||
|
entity: "learnings",
|
||||||
|
operation: "delete",
|
||||||
|
wantCode: http.StatusForbidden,
|
||||||
|
wantMessageIn: `operation "delete" is not allowed for public.learnings`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown operation is rejected",
|
||||||
|
entity: "projects",
|
||||||
|
operation: "scan",
|
||||||
|
wantCode: http.StatusBadRequest,
|
||||||
|
wantMessageIn: `unsupported operation "scan"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
hookCtx := &resolvespec.HookContext{
|
||||||
|
Schema: "public",
|
||||||
|
Entity: tc.entity,
|
||||||
|
Operation: tc.operation,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := rs.Hooks().Execute(resolvespec.BeforeHandle, hookCtx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Execute() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !hookCtx.Abort {
|
||||||
|
t.Fatal("Abort = false, want true")
|
||||||
|
}
|
||||||
|
if hookCtx.AbortCode != tc.wantCode {
|
||||||
|
t.Fatalf("AbortCode = %d, want %d", hookCtx.AbortCode, tc.wantCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(hookCtx.AbortMessage, tc.wantMessageIn) {
|
||||||
|
t.Fatalf("AbortMessage = %q, want substring %q", hookCtx.AbortMessage, tc.wantMessageIn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSpecModelsIncludeLearnings(t *testing.T) {
|
||||||
|
models := resolveSpecModels()
|
||||||
|
for _, model := range models {
|
||||||
|
if model.schema == "public" && model.entity == "learnings" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("resolveSpecModels() missing public.learnings")
|
||||||
|
}
|
||||||
BIN
internal/app/static/icon.png
Normal file
BIN
internal/app/static/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
@@ -12,6 +12,7 @@ var (
|
|||||||
|
|
||||||
faviconICO = mustReadStaticFile("favicon.ico")
|
faviconICO = mustReadStaticFile("favicon.ico")
|
||||||
homeImage = mustReadStaticFile("avelonmemorycrystal.jpg")
|
homeImage = mustReadStaticFile("avelonmemorycrystal.jpg")
|
||||||
|
iconImage = tryReadStaticFile("icon.png")
|
||||||
)
|
)
|
||||||
|
|
||||||
func mustReadStaticFile(name string) []byte {
|
func mustReadStaticFile(name string) []byte {
|
||||||
@@ -22,3 +23,11 @@ func mustReadStaticFile(name string) []byte {
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tryReadStaticFile(name string) []byte {
|
||||||
|
data, err := fs.ReadFile(staticFiles, "static/"+name)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
140
internal/app/status.go
Normal file
140
internal/app/status.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/auth"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/buildinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const connectedWindow = 10 * time.Minute
|
||||||
|
|
||||||
|
type statusAPIResponse struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
BuildDate string `json:"build_date"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
ConnectedCount int `json:"connected_count"`
|
||||||
|
TotalKnown int `json:"total_known"`
|
||||||
|
ConnectedWindow string `json:"connected_window"`
|
||||||
|
Entries []auth.AccessSnapshot `json:"entries"`
|
||||||
|
Metrics auth.AccessMetrics `json:"metrics"`
|
||||||
|
OAuthEnabled bool `json:"oauth_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse {
|
||||||
|
entries := tracker.Snapshot()
|
||||||
|
return statusAPIResponse{
|
||||||
|
Title: "Avelon Memory Crystal Server (AMCS)",
|
||||||
|
Description: "AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.",
|
||||||
|
Version: fallback(info.Version, "dev"),
|
||||||
|
BuildDate: fallback(info.BuildDate, "unknown"),
|
||||||
|
Commit: fallback(info.Commit, "unknown"),
|
||||||
|
ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
|
||||||
|
TotalKnown: len(entries),
|
||||||
|
ConnectedWindow: "last 10 minutes",
|
||||||
|
Entries: entries,
|
||||||
|
Metrics: tracker.Metrics(20),
|
||||||
|
OAuthEnabled: oauthEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallback(value, defaultValue string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/status" && r.URL.Path != "/status" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(statusSnapshot(info, tracker, oauthEnabled, time.Now()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||||
|
if requestPath == "." {
|
||||||
|
requestPath = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestPath != "" {
|
||||||
|
if serveUIAsset(w, r, requestPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serveUIIndex(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveUIAsset(w http.ResponseWriter, r *http.Request, name string) bool {
|
||||||
|
if uiDistFS == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
file, err := uiDistFS.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := fs.ReadFile(uiDistFS, name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeContent(w, r, info.Name(), info.ModTime(), bytes.NewReader(data))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveUIIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if indexHTML == nil {
|
||||||
|
http.Error(w, "ui assets not built", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(indexHTML)
|
||||||
|
}
|
||||||
165
internal/app/status_test.go
Normal file
165
internal/app/status_test.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/auth"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/buildinfo"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatusSnapshotHidesOAuthLinkWhenDisabled(t *testing.T) {
|
||||||
|
tracker := auth.NewAccessTracker()
|
||||||
|
snapshot := statusSnapshot(buildinfo.Info{Version: "v1.2.3", BuildDate: "2026-04-04", Commit: "abc123"}, tracker, false, time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
if snapshot.OAuthEnabled {
|
||||||
|
t.Fatal("OAuthEnabled = true, want false")
|
||||||
|
}
|
||||||
|
if snapshot.ConnectedCount != 0 {
|
||||||
|
t.Fatalf("ConnectedCount = %d, want 0", snapshot.ConnectedCount)
|
||||||
|
}
|
||||||
|
if snapshot.Title == "" {
|
||||||
|
t.Fatal("Title = empty, want non-empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
|
||||||
|
tracker := auth.NewAccessTracker()
|
||||||
|
now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
tracker.Record("client-a", "/files", "127.0.0.1:1234", "tester", "list_projects", now)
|
||||||
|
|
||||||
|
snapshot := statusSnapshot(buildinfo.Info{Version: "v1.2.3"}, tracker, true, now)
|
||||||
|
|
||||||
|
if !snapshot.OAuthEnabled {
|
||||||
|
t.Fatal("OAuthEnabled = false, want true")
|
||||||
|
}
|
||||||
|
if snapshot.ConnectedCount != 1 {
|
||||||
|
t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount)
|
||||||
|
}
|
||||||
|
if len(snapshot.Entries) != 1 {
|
||||||
|
t.Fatalf("len(Entries) = %d, want 1", len(snapshot.Entries))
|
||||||
|
}
|
||||||
|
if snapshot.Entries[0].KeyID != "client-a" || snapshot.Entries[0].LastPath != "/files" {
|
||||||
|
t.Fatalf("entry = %+v, want keyID client-a and path /files", snapshot.Entries[0])
|
||||||
|
}
|
||||||
|
if snapshot.Metrics.TotalRequests != 1 {
|
||||||
|
t.Fatalf("Metrics.TotalRequests = %d, want 1", snapshot.Metrics.TotalRequests)
|
||||||
|
}
|
||||||
|
if snapshot.Metrics.UniqueIPs != 1 {
|
||||||
|
t.Fatalf("Metrics.UniqueIPs = %d, want 1", snapshot.Metrics.UniqueIPs)
|
||||||
|
}
|
||||||
|
if snapshot.Metrics.UniqueAgents != 1 {
|
||||||
|
t.Fatalf("Metrics.UniqueAgents = %d, want 1", snapshot.Metrics.UniqueAgents)
|
||||||
|
}
|
||||||
|
if snapshot.Metrics.UniqueTools != 1 {
|
||||||
|
t.Fatalf("Metrics.UniqueTools = %d, want 1", snapshot.Metrics.UniqueTools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
|
||||||
|
handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
|
||||||
|
t.Fatalf("content-type = %q, want application/json", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload statusAPIResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if payload.Version != "v1" {
|
||||||
|
t.Fatalf("version = %q, want %q", payload.Version, "v1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusAPIHandlerSupportsStatusPath(t *testing.T) {
|
||||||
|
handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/status", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload statusAPIResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if payload.Version != "v1" {
|
||||||
|
t.Fatalf("version = %q, want %q", payload.Version, "v1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHomeHandlerAllowsHead(t *testing.T) {
|
||||||
|
handler := homeHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), false)
|
||||||
|
req := httptest.NewRequest(http.MethodHead, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if body := rec.Body.String(); body != "" {
|
||||||
|
t.Fatalf("body = %q, want empty for HEAD", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHomeHandlerServesIndex(t *testing.T) {
|
||||||
|
handler := homeHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), false)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "<div id=\"app\"></div>") {
|
||||||
|
t.Fatalf("body = %q, want embedded UI index", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddlewareRecordsAuthenticatedAccess(t *testing.T) {
|
||||||
|
keyring, err := auth.NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
|
}
|
||||||
|
tracker := auth.NewAccessTracker()
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
handler := auth.Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, tracker, logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/files", nil)
|
||||||
|
req.Header.Set("x-brain-key", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
snap := tracker.Snapshot()
|
||||||
|
if len(snap) != 1 {
|
||||||
|
t.Fatalf("len(snapshot) = %d, want 1", len(snap))
|
||||||
|
}
|
||||||
|
if snap[0].KeyID != "client-a" || snap[0].LastPath != "/files" {
|
||||||
|
t.Fatalf("snapshot[0] = %+v, want keyID client-a and path /files", snap[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
22
internal/app/ui_assets.go
Normal file
22
internal/app/ui_assets.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed ui/dist
|
||||||
|
uiFiles embed.FS
|
||||||
|
uiDistFS fs.FS
|
||||||
|
indexHTML []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dist, err := fs.Sub(uiFiles, "ui/dist")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uiDistFS = dist
|
||||||
|
indexHTML, _ = fs.ReadFile(uiDistFS, "index.html")
|
||||||
|
}
|
||||||
173
internal/auth/access_tracker.go
Normal file
173
internal/auth/access_tracker.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccessSnapshot struct {
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
LastPath string `json:"last_path"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
RequestCount int `json:"request_count"`
|
||||||
|
LastAccessedAt time.Time `json:"last_accessed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccessTracker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries map[string]AccessSnapshot
|
||||||
|
ipCounts map[string]int
|
||||||
|
agentCounts map[string]int
|
||||||
|
toolCounts map[string]int
|
||||||
|
totalRequests int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAccessTracker() *AccessTracker {
|
||||||
|
return &AccessTracker{
|
||||||
|
entries: make(map[string]AccessSnapshot),
|
||||||
|
ipCounts: make(map[string]int),
|
||||||
|
agentCounts: make(map[string]int),
|
||||||
|
toolCounts: make(map[string]int),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AccessTracker) Record(keyID, path, remoteAddr, userAgent, toolName string, now time.Time) {
|
||||||
|
if t == nil || keyID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
normalizedRemoteAddr := normalizeRemoteAddr(remoteAddr)
|
||||||
|
|
||||||
|
entry := t.entries[keyID]
|
||||||
|
entry.KeyID = keyID
|
||||||
|
entry.LastPath = path
|
||||||
|
entry.RemoteAddr = normalizedRemoteAddr
|
||||||
|
entry.UserAgent = userAgent
|
||||||
|
entry.LastAccessedAt = now.UTC()
|
||||||
|
entry.RequestCount++
|
||||||
|
t.entries[keyID] = entry
|
||||||
|
t.totalRequests++
|
||||||
|
|
||||||
|
if normalizedRemoteAddr != "" {
|
||||||
|
t.ipCounts[normalizedRemoteAddr]++
|
||||||
|
}
|
||||||
|
if userAgent != "" {
|
||||||
|
t.agentCounts[userAgent]++
|
||||||
|
}
|
||||||
|
if tool := strings.TrimSpace(toolName); tool != "" {
|
||||||
|
t.toolCounts[tool]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRemoteAddr(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(trimmed)
|
||||||
|
if err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AccessTracker) Snapshot() []AccessSnapshot {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
items := make([]AccessSnapshot, 0, len(t.entries))
|
||||||
|
for _, entry := range t.entries {
|
||||||
|
items = append(items, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].LastAccessedAt.After(items[j].LastAccessedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AccessTracker) ConnectedCount(now time.Time, window time.Duration) int {
|
||||||
|
if t == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := now.UTC().Add(-window)
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, entry := range t.entries {
|
||||||
|
if !entry.LastAccessedAt.Before(cutoff) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestAggregate struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
RequestCount int `json:"request_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccessMetrics struct {
|
||||||
|
TotalRequests int `json:"total_requests"`
|
||||||
|
UniquePrincipals int `json:"unique_principals"`
|
||||||
|
UniqueIPs int `json:"unique_ips"`
|
||||||
|
UniqueAgents int `json:"unique_agents"`
|
||||||
|
UniqueTools int `json:"unique_tools"`
|
||||||
|
TopIPs []RequestAggregate `json:"top_ips"`
|
||||||
|
TopAgents []RequestAggregate `json:"top_agents"`
|
||||||
|
TopTools []RequestAggregate `json:"top_tools"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AccessTracker) Metrics(topN int) AccessMetrics {
|
||||||
|
if t == nil {
|
||||||
|
return AccessMetrics{}
|
||||||
|
}
|
||||||
|
if topN <= 0 {
|
||||||
|
topN = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
return AccessMetrics{
|
||||||
|
TotalRequests: t.totalRequests,
|
||||||
|
UniquePrincipals: len(t.entries),
|
||||||
|
UniqueIPs: len(t.ipCounts),
|
||||||
|
UniqueAgents: len(t.agentCounts),
|
||||||
|
UniqueTools: len(t.toolCounts),
|
||||||
|
TopIPs: topAggregates(t.ipCounts, topN),
|
||||||
|
TopAgents: topAggregates(t.agentCounts, topN),
|
||||||
|
TopTools: topAggregates(t.toolCounts, topN),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func topAggregates(items map[string]int, topN int) []RequestAggregate {
|
||||||
|
out := make([]RequestAggregate, 0, len(items))
|
||||||
|
for key, count := range items {
|
||||||
|
out = append(out, RequestAggregate{Key: key, RequestCount: count})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].RequestCount == out[j].RequestCount {
|
||||||
|
return out[i].Key < out[j].Key
|
||||||
|
}
|
||||||
|
return out[i].RequestCount > out[j].RequestCount
|
||||||
|
})
|
||||||
|
if len(out) > topN {
|
||||||
|
out = out[:topN]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
96
internal/auth/access_tracker_test.go
Normal file
96
internal/auth/access_tracker_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccessTrackerRecordAndSnapshot(t *testing.T) {
|
||||||
|
tracker := NewAccessTracker()
|
||||||
|
older := time.Date(2026, 4, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
newer := older.Add(2 * time.Minute)
|
||||||
|
|
||||||
|
tracker.Record("client-a", "/files", "10.0.0.1:1234", "agent-a", "", older)
|
||||||
|
tracker.Record("client-b", "/mcp", "10.0.0.2:1234", "agent-b", "list_projects", newer)
|
||||||
|
tracker.Record("client-a", "/files/1", "10.0.0.1:1234", "agent-a2", "", newer.Add(30*time.Second))
|
||||||
|
|
||||||
|
snap := tracker.Snapshot()
|
||||||
|
if len(snap) != 2 {
|
||||||
|
t.Fatalf("len(snapshot) = %d, want 2", len(snap))
|
||||||
|
}
|
||||||
|
if snap[0].KeyID != "client-a" {
|
||||||
|
t.Fatalf("snapshot[0].KeyID = %q, want client-a", snap[0].KeyID)
|
||||||
|
}
|
||||||
|
if snap[0].RequestCount != 2 {
|
||||||
|
t.Fatalf("snapshot[0].RequestCount = %d, want 2", snap[0].RequestCount)
|
||||||
|
}
|
||||||
|
if snap[0].LastPath != "/files/1" {
|
||||||
|
t.Fatalf("snapshot[0].LastPath = %q, want /files/1", snap[0].LastPath)
|
||||||
|
}
|
||||||
|
if snap[0].UserAgent != "agent-a2" {
|
||||||
|
t.Fatalf("snapshot[0].UserAgent = %q, want agent-a2", snap[0].UserAgent)
|
||||||
|
}
|
||||||
|
if snap[0].RemoteAddr != "10.0.0.1" {
|
||||||
|
t.Fatalf("snapshot[0].RemoteAddr = %q, want 10.0.0.1", snap[0].RemoteAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessTrackerConnectedCount(t *testing.T) {
|
||||||
|
tracker := NewAccessTracker()
|
||||||
|
now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
tracker.Record("recent", "/mcp", "", "", "", now.Add(-2*time.Minute))
|
||||||
|
tracker.Record("stale", "/mcp", "", "", "", now.Add(-11*time.Minute))
|
||||||
|
|
||||||
|
if got := tracker.ConnectedCount(now, 10*time.Minute); got != 1 {
|
||||||
|
t.Fatalf("ConnectedCount() = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessTrackerMetrics(t *testing.T) {
|
||||||
|
tracker := NewAccessTracker()
|
||||||
|
now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
tracker.Record("client-a", "/mcp", "10.0.0.1:1234", "agent-a", "list_projects", now)
|
||||||
|
tracker.Record("client-a", "/mcp", "10.0.0.1:1234", "agent-a", "list_projects", now.Add(1*time.Second))
|
||||||
|
tracker.Record("client-b", "/files", "10.0.0.2:5678", "agent-b", "", now.Add(2*time.Second))
|
||||||
|
tracker.Record("client-c", "/files", "10.0.0.2:5678", "agent-b", "search_thoughts", now.Add(3*time.Second))
|
||||||
|
|
||||||
|
metrics := tracker.Metrics(5)
|
||||||
|
if metrics.TotalRequests != 4 {
|
||||||
|
t.Fatalf("TotalRequests = %d, want 4", metrics.TotalRequests)
|
||||||
|
}
|
||||||
|
if metrics.UniquePrincipals != 3 {
|
||||||
|
t.Fatalf("UniquePrincipals = %d, want 3", metrics.UniquePrincipals)
|
||||||
|
}
|
||||||
|
if metrics.UniqueIPs != 2 {
|
||||||
|
t.Fatalf("UniqueIPs = %d, want 2", metrics.UniqueIPs)
|
||||||
|
}
|
||||||
|
if metrics.UniqueAgents != 2 {
|
||||||
|
t.Fatalf("UniqueAgents = %d, want 2", metrics.UniqueAgents)
|
||||||
|
}
|
||||||
|
if metrics.UniqueTools != 2 {
|
||||||
|
t.Fatalf("UniqueTools = %d, want 2", metrics.UniqueTools)
|
||||||
|
}
|
||||||
|
if len(metrics.TopIPs) != 2 {
|
||||||
|
t.Fatalf("len(TopIPs) = %d, want 2", len(metrics.TopIPs))
|
||||||
|
}
|
||||||
|
if metrics.TopIPs[0].RequestCount != 2 || metrics.TopIPs[1].RequestCount != 2 {
|
||||||
|
t.Fatalf("TopIPs counts = %+v, want both counts to be 2", metrics.TopIPs)
|
||||||
|
}
|
||||||
|
if metrics.TopIPs[0].Key != "10.0.0.1" && metrics.TopIPs[0].Key != "10.0.0.2" {
|
||||||
|
t.Fatalf("TopIPs[0].Key = %q, want normalized IP", metrics.TopIPs[0].Key)
|
||||||
|
}
|
||||||
|
if len(metrics.TopAgents) != 2 {
|
||||||
|
t.Fatalf("len(TopAgents) = %d, want 2", len(metrics.TopAgents))
|
||||||
|
}
|
||||||
|
if metrics.TopAgents[0].RequestCount != 2 || metrics.TopAgents[1].RequestCount != 2 {
|
||||||
|
t.Fatalf("TopAgents counts = %+v, want both counts to be 2", metrics.TopAgents)
|
||||||
|
}
|
||||||
|
if len(metrics.TopTools) != 2 {
|
||||||
|
t.Fatalf("len(TopTools) = %d, want 2", len(metrics.TopTools))
|
||||||
|
}
|
||||||
|
if metrics.TopTools[0].Key != "list_projects" || metrics.TopTools[0].RequestCount != 2 {
|
||||||
|
t.Fatalf("TopTools[0] = %+v, want list_projects with count 2", metrics.TopTools[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -8,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/observability"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testLogger() *slog.Logger {
|
func testLogger() *slog.Logger {
|
||||||
@@ -39,7 +42,7 @@ func TestMiddlewareAllowsHeaderAuthAndSetsContext(t *testing.T) {
|
|||||||
t.Fatalf("NewKeyring() error = %v", err)
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
keyID, ok := KeyIDFromContext(r.Context())
|
keyID, ok := KeyIDFromContext(r.Context())
|
||||||
if !ok || keyID != "client-a" {
|
if !ok || keyID != "client-a" {
|
||||||
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
||||||
@@ -63,7 +66,7 @@ func TestMiddlewareAllowsBearerAuthAndSetsContext(t *testing.T) {
|
|||||||
t.Fatalf("NewKeyring() error = %v", err)
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
keyID, ok := KeyIDFromContext(r.Context())
|
keyID, ok := KeyIDFromContext(r.Context())
|
||||||
if !ok || keyID != "client-a" {
|
if !ok || keyID != "client-a" {
|
||||||
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
||||||
@@ -90,7 +93,7 @@ func TestMiddlewarePrefersExplicitHeaderOverBearerAuth(t *testing.T) {
|
|||||||
t.Fatalf("NewKeyring() error = %v", err)
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
keyID, ok := KeyIDFromContext(r.Context())
|
keyID, ok := KeyIDFromContext(r.Context())
|
||||||
if !ok || keyID != "client-a" {
|
if !ok || keyID != "client-a" {
|
||||||
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
||||||
@@ -119,7 +122,7 @@ func TestMiddlewareAllowsQueryParamWhenEnabled(t *testing.T) {
|
|||||||
HeaderName: "x-brain-key",
|
HeaderName: "x-brain-key",
|
||||||
QueryParam: "key",
|
QueryParam: "key",
|
||||||
AllowQueryParam: true,
|
AllowQueryParam: true,
|
||||||
}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -138,7 +141,7 @@ func TestMiddlewareRejectsMissingOrInvalidKey(t *testing.T) {
|
|||||||
t.Fatalf("NewKeyring() error = %v", err)
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -157,3 +160,81 @@ func TestMiddlewareRejectsMissingOrInvalidKey(t *testing.T) {
|
|||||||
t.Fatalf("invalid key status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
t.Fatalf("invalid key status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMiddlewareRecordsForwardedRemoteAddr(t *testing.T) {
|
||||||
|
keyring, err := NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
|
}
|
||||||
|
tracker := NewAccessTracker()
|
||||||
|
|
||||||
|
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, tracker, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.5:2222"
|
||||||
|
req.Header.Set("x-brain-key", "secret")
|
||||||
|
req.Header.Set("X-Real-IP", "203.0.113.99")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := tracker.Snapshot()
|
||||||
|
if len(snap) != 1 {
|
||||||
|
t.Fatalf("len(snapshot) = %d, want 1", len(snap))
|
||||||
|
}
|
||||||
|
if snap[0].RemoteAddr != "203.0.113.99" {
|
||||||
|
t.Fatalf("snapshot remote_addr = %q, want %q", snap[0].RemoteAddr, "203.0.113.99")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddlewareRecordsMCPToolUsage(t *testing.T) {
|
||||||
|
keyring, err := NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
|
}
|
||||||
|
tracker := NewAccessTracker()
|
||||||
|
logger := testLogger()
|
||||||
|
|
||||||
|
authenticated := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, tracker, logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
handler := observability.AccessLog(logger)(authenticated)
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "1",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": map[string]any{
|
||||||
|
"name": "list_projects",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||||
|
req.Header.Set("x-brain-key", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics := tracker.Metrics(10)
|
||||||
|
if metrics.UniqueTools != 1 {
|
||||||
|
t.Fatalf("UniqueTools = %d, want 1", metrics.UniqueTools)
|
||||||
|
}
|
||||||
|
if len(metrics.TopTools) != 1 {
|
||||||
|
t.Fatalf("len(TopTools) = %d, want 1", len(metrics.TopTools))
|
||||||
|
}
|
||||||
|
if metrics.TopTools[0].Key != "list_projects" || metrics.TopTools[0].RequestCount != 1 {
|
||||||
|
t.Fatalf("TopTools[0] = %+v, want list_projects with count 1", metrics.TopTools[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,30 +6,47 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/observability"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/requestip"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const keyIDContextKey contextKey = "auth.key_id"
|
const keyIDContextKey contextKey = "auth.key_id"
|
||||||
|
|
||||||
func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthRegistry, tokenStore *TokenStore, log *slog.Logger) func(http.Handler) http.Handler {
|
func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthRegistry, tokenStore *TokenStore, tracker *AccessTracker, log *slog.Logger) func(http.Handler) http.Handler {
|
||||||
headerName := cfg.HeaderName
|
headerName := cfg.HeaderName
|
||||||
if headerName == "" {
|
if headerName == "" {
|
||||||
headerName = "x-brain-key"
|
headerName = "x-brain-key"
|
||||||
}
|
}
|
||||||
|
recordAccess := func(r *http.Request, keyID string) {
|
||||||
|
if tracker != nil {
|
||||||
|
tracker.Record(
|
||||||
|
keyID,
|
||||||
|
r.URL.Path,
|
||||||
|
requestip.FromRequest(r),
|
||||||
|
r.UserAgent(),
|
||||||
|
observability.MCPToolFromContext(r.Context()),
|
||||||
|
time.Now(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
remoteAddr := requestip.FromRequest(r)
|
||||||
// 1. Custom header → keyring only.
|
// 1. Custom header → keyring only.
|
||||||
if keyring != nil {
|
if keyring != nil {
|
||||||
if token := strings.TrimSpace(r.Header.Get(headerName)); token != "" {
|
if token := strings.TrimSpace(r.Header.Get(headerName)); token != "" {
|
||||||
keyID, ok := keyring.Lookup(token)
|
keyID, ok := keyring.Lookup(token)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Warn("authentication failed", slog.String("remote_addr", r.RemoteAddr))
|
log.Warn("authentication failed", slog.String("remote_addr", remoteAddr))
|
||||||
http.Error(w, "invalid API key", http.StatusUnauthorized)
|
http.Error(w, "invalid API key", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,17 +56,19 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
if bearer := extractBearer(r); bearer != "" {
|
if bearer := extractBearer(r); bearer != "" {
|
||||||
if tokenStore != nil {
|
if tokenStore != nil {
|
||||||
if keyID, ok := tokenStore.Lookup(bearer); ok {
|
if keyID, ok := tokenStore.Lookup(bearer); ok {
|
||||||
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if keyring != nil {
|
if keyring != nil {
|
||||||
if keyID, ok := keyring.Lookup(bearer); ok {
|
if keyID, ok := keyring.Lookup(bearer); ok {
|
||||||
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Warn("bearer token rejected", slog.String("remote_addr", r.RemoteAddr))
|
log.Warn("bearer token rejected", slog.String("remote_addr", remoteAddr))
|
||||||
http.Error(w, "invalid token or API key", http.StatusUnauthorized)
|
http.Error(w, "invalid token or API key", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -62,10 +81,11 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
}
|
}
|
||||||
keyID, ok := oauthRegistry.Lookup(clientID, clientSecret)
|
keyID, ok := oauthRegistry.Lookup(clientID, clientSecret)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Warn("oauth client authentication failed", slog.String("remote_addr", r.RemoteAddr))
|
log.Warn("oauth client authentication failed", slog.String("remote_addr", remoteAddr))
|
||||||
http.Error(w, "invalid OAuth client credentials", http.StatusUnauthorized)
|
http.Error(w, "invalid OAuth client credentials", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -75,10 +95,11 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
if token := strings.TrimSpace(r.URL.Query().Get(cfg.QueryParam)); token != "" {
|
if token := strings.TrimSpace(r.URL.Query().Get(cfg.QueryParam)); token != "" {
|
||||||
keyID, ok := keyring.Lookup(token)
|
keyID, ok := keyring.Lookup(token)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Warn("authentication failed", slog.String("remote_addr", r.RemoteAddr))
|
log.Warn("authentication failed", slog.String("remote_addr", remoteAddr))
|
||||||
http.Error(w, "invalid API key", http.StatusUnauthorized)
|
http.Error(w, "invalid API key", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestMiddlewareAllowsOAuthBasicAuthAndSetsContext(t *testing.T) {
|
|||||||
t.Fatalf("NewOAuthRegistry() error = %v", err)
|
t.Fatalf("NewOAuthRegistry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
keyID, ok := KeyIDFromContext(r.Context())
|
keyID, ok := KeyIDFromContext(r.Context())
|
||||||
if !ok || keyID != "oauth-client" {
|
if !ok || keyID != "oauth-client" {
|
||||||
t.Fatalf("KeyIDFromContext() = (%q, %v), want (oauth-client, true)", keyID, ok)
|
t.Fatalf("KeyIDFromContext() = (%q, %v), want (oauth-client, true)", keyID, ok)
|
||||||
@@ -70,7 +70,7 @@ func TestMiddlewareRejectsOAuthMissingOrInvalidCredentials(t *testing.T) {
|
|||||||
t.Fatalf("NewOAuthRegistry() error = %v", err)
|
t.Fatalf("NewOAuthRegistry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
MCP MCPConfig `yaml:"mcp"`
|
MCP MCPConfig `yaml:"mcp"`
|
||||||
Auth AuthConfig `yaml:"auth"`
|
Auth AuthConfig `yaml:"auth"`
|
||||||
@@ -32,10 +33,13 @@ type ServerConfig struct {
|
|||||||
|
|
||||||
type MCPConfig struct {
|
type MCPConfig struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
|
SSEPath string `yaml:"sse_path"`
|
||||||
ServerName string `yaml:"server_name"`
|
ServerName string `yaml:"server_name"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
Transport string `yaml:"transport"`
|
Transport string `yaml:"transport"`
|
||||||
SessionTimeout time.Duration `yaml:"session_timeout"`
|
SessionTimeout time.Duration `yaml:"session_timeout"`
|
||||||
|
PublicURL string `yaml:"public_url"`
|
||||||
|
Instructions string `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
@@ -71,52 +75,82 @@ type DatabaseConfig struct {
|
|||||||
MaxConnIdleTime time.Duration `yaml:"max_conn_idle_time"`
|
MaxConnIdleTime time.Duration `yaml:"max_conn_idle_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AIConfig (v2): named providers + per-role chains.
|
||||||
type AIConfig struct {
|
type AIConfig struct {
|
||||||
Provider string `yaml:"provider"`
|
Providers map[string]ProviderConfig `yaml:"providers"`
|
||||||
Embeddings AIEmbeddingConfig `yaml:"embeddings"`
|
Embeddings EmbeddingsRoleConfig `yaml:"embeddings"`
|
||||||
Metadata AIMetadataConfig `yaml:"metadata"`
|
Metadata MetadataRoleConfig `yaml:"metadata"`
|
||||||
LiteLLM LiteLLMConfig `yaml:"litellm"`
|
Background *BackgroundRolesConfig `yaml:"background,omitempty"`
|
||||||
Ollama OllamaConfig `yaml:"ollama"`
|
|
||||||
OpenRouter OpenRouterAIConfig `yaml:"openrouter"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIEmbeddingConfig struct {
|
type ProviderConfig struct {
|
||||||
Model string `yaml:"model"`
|
Type string `yaml:"type"`
|
||||||
Dimensions int `yaml:"dimensions"`
|
BaseURL string `yaml:"base_url"`
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
RequestHeaders map[string]string `yaml:"request_headers,omitempty"`
|
||||||
|
AppName string `yaml:"app_name,omitempty"`
|
||||||
|
SiteURL string `yaml:"site_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIMetadataConfig struct {
|
type RoleTarget struct {
|
||||||
Model string `yaml:"model"`
|
Provider string `yaml:"provider"`
|
||||||
FallbackModels []string `yaml:"fallback_models"`
|
Model string `yaml:"model"`
|
||||||
FallbackModel string `yaml:"fallback_model"` // legacy single fallback
|
}
|
||||||
|
|
||||||
|
type RoleChain struct {
|
||||||
|
Primary RoleTarget `yaml:"primary"`
|
||||||
|
Fallbacks []RoleTarget `yaml:"fallbacks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmbeddingsRoleConfig struct {
|
||||||
|
Dimensions int `yaml:"dimensions"`
|
||||||
|
Primary RoleTarget `yaml:"primary"`
|
||||||
|
Fallbacks []RoleTarget `yaml:"fallbacks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetadataRoleConfig struct {
|
||||||
Temperature float64 `yaml:"temperature"`
|
Temperature float64 `yaml:"temperature"`
|
||||||
LogConversations bool `yaml:"log_conversations"`
|
LogConversations bool `yaml:"log_conversations"`
|
||||||
Timeout time.Duration `yaml:"timeout"`
|
Timeout time.Duration `yaml:"timeout"`
|
||||||
|
Primary RoleTarget `yaml:"primary"`
|
||||||
|
Fallbacks []RoleTarget `yaml:"fallbacks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LiteLLMConfig struct {
|
// BackgroundRolesConfig overrides the foreground chains for background workers
|
||||||
BaseURL string `yaml:"base_url"`
|
// (backfill_embeddings, metadata_retry, reparse_metadata). Either field may be
|
||||||
APIKey string `yaml:"api_key"`
|
// nil to inherit the foreground role unchanged.
|
||||||
UseResponsesAPI bool `yaml:"use_responses_api"`
|
type BackgroundRolesConfig struct {
|
||||||
RequestHeaders map[string]string `yaml:"request_headers"`
|
Embeddings *RoleChain `yaml:"embeddings,omitempty"`
|
||||||
EmbeddingModel string `yaml:"embedding_model"`
|
Metadata *RoleChain `yaml:"metadata,omitempty"`
|
||||||
MetadataModel string `yaml:"metadata_model"`
|
|
||||||
FallbackMetadataModels []string `yaml:"fallback_metadata_models"`
|
|
||||||
FallbackMetadataModel string `yaml:"fallback_metadata_model"` // legacy single fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OllamaConfig struct {
|
// Chain returns primary followed by fallbacks (deduped, blanks dropped).
|
||||||
BaseURL string `yaml:"base_url"`
|
func (e EmbeddingsRoleConfig) Chain() []RoleTarget {
|
||||||
APIKey string `yaml:"api_key"`
|
return dedupeTargets(append([]RoleTarget{e.Primary}, e.Fallbacks...))
|
||||||
RequestHeaders map[string]string `yaml:"request_headers"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenRouterAIConfig struct {
|
func (m MetadataRoleConfig) Chain() []RoleTarget {
|
||||||
BaseURL string `yaml:"base_url"`
|
return dedupeTargets(append([]RoleTarget{m.Primary}, m.Fallbacks...))
|
||||||
APIKey string `yaml:"api_key"`
|
}
|
||||||
AppName string `yaml:"app_name"`
|
|
||||||
SiteURL string `yaml:"site_url"`
|
func (c RoleChain) AsTargets() []RoleTarget {
|
||||||
ExtraHeaders map[string]string `yaml:"extra_headers"`
|
return dedupeTargets(append([]RoleTarget{c.Primary}, c.Fallbacks...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeTargets(in []RoleTarget) []RoleTarget {
|
||||||
|
out := make([]RoleTarget, 0, len(in))
|
||||||
|
seen := make(map[RoleTarget]struct{}, len(in))
|
||||||
|
for _, t := range in {
|
||||||
|
if t.Provider == "" || t.Model == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[t]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[t] = struct{}{}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
type CaptureConfig struct {
|
type CaptureConfig struct {
|
||||||
@@ -161,45 +195,3 @@ type MetadataRetryConfig struct {
|
|||||||
MaxPerRun int `yaml:"max_per_run"`
|
MaxPerRun int `yaml:"max_per_run"`
|
||||||
IncludeArchived bool `yaml:"include_archived"`
|
IncludeArchived bool `yaml:"include_archived"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c AIMetadataConfig) EffectiveFallbackModels() []string {
|
|
||||||
models := make([]string, 0, len(c.FallbackModels)+1)
|
|
||||||
for _, model := range c.FallbackModels {
|
|
||||||
if model != "" {
|
|
||||||
models = append(models, model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.FallbackModel != "" {
|
|
||||||
models = append(models, c.FallbackModel)
|
|
||||||
}
|
|
||||||
return dedupeNonEmpty(models)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c LiteLLMConfig) EffectiveFallbackMetadataModels() []string {
|
|
||||||
models := make([]string, 0, len(c.FallbackMetadataModels)+1)
|
|
||||||
for _, model := range c.FallbackMetadataModels {
|
|
||||||
if model != "" {
|
|
||||||
models = append(models, model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.FallbackMetadataModel != "" {
|
|
||||||
models = append(models, c.FallbackMetadataModel)
|
|
||||||
}
|
|
||||||
return dedupeNonEmpty(models)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dedupeNonEmpty(values []string) []string {
|
|
||||||
seen := make(map[string]struct{}, len(values))
|
|
||||||
out := make([]string, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[value]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[value] = struct{}{}
|
|
||||||
out = append(out, value)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,6 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Load(explicitPath string) (*Config, string, error) {
|
func Load(explicitPath string) (*Config, string, error) {
|
||||||
|
return LoadWithLogger(explicitPath, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadWithLogger is Load with a logger surface for migration notices. Passing
|
||||||
|
// nil is fine — migration events will simply not be logged.
|
||||||
|
func LoadWithLogger(explicitPath string, log *slog.Logger) (*Config, string, error) {
|
||||||
path := ResolvePath(explicitPath)
|
path := ResolvePath(explicitPath)
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
@@ -19,10 +26,38 @@ func Load(explicitPath string) (*Config, string, error) {
|
|||||||
return nil, path, fmt.Errorf("read config %q: %w", path, err)
|
return nil, path, fmt.Errorf("read config %q: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := defaultConfig()
|
raw := map[string]any{}
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
return nil, path, fmt.Errorf("decode config %q: %w", path, err)
|
return nil, path, fmt.Errorf("decode config %q: %w", path, err)
|
||||||
}
|
}
|
||||||
|
if raw == nil {
|
||||||
|
raw = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
applied, err := Migrate(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, path, fmt.Errorf("migrate config %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(applied) > 0 {
|
||||||
|
if log != nil {
|
||||||
|
for _, step := range applied {
|
||||||
|
log.Warn("config migrated in memory",
|
||||||
|
slog.String("path", path),
|
||||||
|
slog.Int("from_version", step.From),
|
||||||
|
slog.Int("to_version", step.To),
|
||||||
|
slog.String("describe", step.Describe),
|
||||||
|
slog.String("hint", "persist with amcs-migrate-config"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := decodeTyped(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, path, fmt.Errorf("decode migrated config %q: %w", path, err)
|
||||||
|
}
|
||||||
|
cfg.Version = CurrentConfigVersion
|
||||||
|
|
||||||
applyEnvOverrides(&cfg)
|
applyEnvOverrides(&cfg)
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
@@ -32,6 +67,18 @@ func Load(explicitPath string) (*Config, string, error) {
|
|||||||
return &cfg, path, nil
|
return &cfg, path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeTyped(raw map[string]any) (Config, error) {
|
||||||
|
out, err := yaml.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("re-marshal migrated config: %w", err)
|
||||||
|
}
|
||||||
|
cfg := defaultConfig()
|
||||||
|
if err := yaml.Unmarshal(out, &cfg); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ResolvePath(explicitPath string) string {
|
func ResolvePath(explicitPath string) string {
|
||||||
if path := strings.TrimSpace(explicitPath); path != "" {
|
if path := strings.TrimSpace(explicitPath); path != "" {
|
||||||
if path != ".yaml" && path != ".yml" {
|
if path != ".yaml" && path != ".yml" {
|
||||||
@@ -49,6 +96,7 @@ func ResolvePath(explicitPath string) string {
|
|||||||
func defaultConfig() Config {
|
func defaultConfig() Config {
|
||||||
info := buildinfo.Current()
|
info := buildinfo.Current()
|
||||||
return Config{
|
return Config{
|
||||||
|
Version: CurrentConfigVersion,
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
Host: "0.0.0.0",
|
Host: "0.0.0.0",
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
@@ -58,6 +106,7 @@ func defaultConfig() Config {
|
|||||||
},
|
},
|
||||||
MCP: MCPConfig{
|
MCP: MCPConfig{
|
||||||
Path: "/mcp",
|
Path: "/mcp",
|
||||||
|
SSEPath: "/sse",
|
||||||
ServerName: "amcs",
|
ServerName: "amcs",
|
||||||
Version: info.Version,
|
Version: info.Version,
|
||||||
Transport: "streamable_http",
|
Transport: "streamable_http",
|
||||||
@@ -68,20 +117,14 @@ func defaultConfig() Config {
|
|||||||
QueryParam: "key",
|
QueryParam: "key",
|
||||||
},
|
},
|
||||||
AI: AIConfig{
|
AI: AIConfig{
|
||||||
Provider: "litellm",
|
Providers: map[string]ProviderConfig{},
|
||||||
Embeddings: AIEmbeddingConfig{
|
Embeddings: EmbeddingsRoleConfig{
|
||||||
Model: "openai/text-embedding-3-small",
|
|
||||||
Dimensions: 1536,
|
Dimensions: 1536,
|
||||||
},
|
},
|
||||||
Metadata: AIMetadataConfig{
|
Metadata: MetadataRoleConfig{
|
||||||
Model: "gpt-4o-mini",
|
|
||||||
Temperature: 0.1,
|
Temperature: 0.1,
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
},
|
},
|
||||||
Ollama: OllamaConfig{
|
|
||||||
BaseURL: "http://localhost:11434/v1",
|
|
||||||
APIKey: "ollama",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Capture: CaptureConfig{
|
Capture: CaptureConfig{
|
||||||
Source: DefaultSource,
|
Source: DefaultSource,
|
||||||
@@ -117,11 +160,13 @@ func defaultConfig() Config {
|
|||||||
|
|
||||||
func applyEnvOverrides(cfg *Config) {
|
func applyEnvOverrides(cfg *Config) {
|
||||||
overrideString(&cfg.Database.URL, "AMCS_DATABASE_URL")
|
overrideString(&cfg.Database.URL, "AMCS_DATABASE_URL")
|
||||||
overrideString(&cfg.AI.LiteLLM.BaseURL, "AMCS_LITELLM_BASE_URL")
|
overrideString(&cfg.MCP.PublicURL, "AMCS_PUBLIC_URL")
|
||||||
overrideString(&cfg.AI.LiteLLM.APIKey, "AMCS_LITELLM_API_KEY")
|
|
||||||
overrideString(&cfg.AI.Ollama.BaseURL, "AMCS_OLLAMA_BASE_URL")
|
overrideProviderField(cfg, "AMCS_LITELLM_BASE_URL", "litellm", func(p *ProviderConfig, v string) { p.BaseURL = v })
|
||||||
overrideString(&cfg.AI.Ollama.APIKey, "AMCS_OLLAMA_API_KEY")
|
overrideProviderField(cfg, "AMCS_LITELLM_API_KEY", "litellm", func(p *ProviderConfig, v string) { p.APIKey = v })
|
||||||
overrideString(&cfg.AI.OpenRouter.APIKey, "AMCS_OPENROUTER_API_KEY")
|
overrideProviderField(cfg, "AMCS_OLLAMA_BASE_URL", "ollama", func(p *ProviderConfig, v string) { p.BaseURL = v })
|
||||||
|
overrideProviderField(cfg, "AMCS_OLLAMA_API_KEY", "ollama", func(p *ProviderConfig, v string) { p.APIKey = v })
|
||||||
|
overrideProviderField(cfg, "AMCS_OPENROUTER_API_KEY", "openrouter", func(p *ProviderConfig, v string) { p.APIKey = v })
|
||||||
|
|
||||||
if value, ok := os.LookupEnv("AMCS_SERVER_PORT"); ok {
|
if value, ok := os.LookupEnv("AMCS_SERVER_PORT"); ok {
|
||||||
if port, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
|
if port, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
|
||||||
@@ -130,6 +175,24 @@ func applyEnvOverrides(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// overrideProviderField applies an env var to every configured provider of the
|
||||||
|
// given type. This preserves the v1 behaviour where e.g. AMCS_LITELLM_API_KEY
|
||||||
|
// rewrote the single litellm block — in v2 it rewrites every litellm provider.
|
||||||
|
func overrideProviderField(cfg *Config, envKey, providerType string, apply func(*ProviderConfig, string)) {
|
||||||
|
value, ok := os.LookupEnv(envKey)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
for name, p := range cfg.AI.Providers {
|
||||||
|
if p.Type != providerType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
apply(&p, value)
|
||||||
|
cfg.AI.Providers[name] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func overrideString(target *string, envKey string) {
|
func overrideString(target *string, envKey string) {
|
||||||
if value, ok := os.LookupEnv(envKey); ok {
|
if value, ok := os.LookupEnv(envKey); ok {
|
||||||
*target = strings.TrimSpace(value)
|
*target = strings.TrimSpace(value)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -31,9 +32,8 @@ func TestResolvePathIgnoresBareYAMLExtension(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadAppliesEnvOverrides(t *testing.T) {
|
const v2ConfigYAML = `
|
||||||
configPath := filepath.Join(t.TempDir(), "test.yaml")
|
version: 2
|
||||||
if err := os.WriteFile(configPath, []byte(`
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
mcp:
|
mcp:
|
||||||
@@ -46,18 +46,30 @@ auth:
|
|||||||
database:
|
database:
|
||||||
url: "postgres://from-file"
|
url: "postgres://from-file"
|
||||||
ai:
|
ai:
|
||||||
provider: "litellm"
|
providers:
|
||||||
|
default:
|
||||||
|
type: "litellm"
|
||||||
|
base_url: "http://localhost:4000/v1"
|
||||||
|
api_key: "file-key"
|
||||||
embeddings:
|
embeddings:
|
||||||
dimensions: 1536
|
dimensions: 1536
|
||||||
litellm:
|
primary:
|
||||||
base_url: "http://localhost:4000/v1"
|
provider: "default"
|
||||||
api_key: "file-key"
|
model: "text-embed"
|
||||||
|
metadata:
|
||||||
|
primary:
|
||||||
|
provider: "default"
|
||||||
|
model: "gpt-4"
|
||||||
search:
|
search:
|
||||||
default_limit: 10
|
default_limit: 10
|
||||||
max_limit: 50
|
max_limit: 50
|
||||||
logging:
|
logging:
|
||||||
level: "info"
|
level: "info"
|
||||||
`), 0o600); err != nil {
|
`
|
||||||
|
|
||||||
|
func TestLoadAppliesEnvOverrides(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "test.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte(v2ConfigYAML), 0o600); err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +88,8 @@ logging:
|
|||||||
if cfg.Database.URL != "postgres://from-env" {
|
if cfg.Database.URL != "postgres://from-env" {
|
||||||
t.Fatalf("database url = %q, want env override", cfg.Database.URL)
|
t.Fatalf("database url = %q, want env override", cfg.Database.URL)
|
||||||
}
|
}
|
||||||
if cfg.AI.LiteLLM.APIKey != "env-key" {
|
if cfg.AI.Providers["default"].APIKey != "env-key" {
|
||||||
t.Fatalf("litellm api key = %q, want env override", cfg.AI.LiteLLM.APIKey)
|
t.Fatalf("litellm api key = %q, want env override", cfg.AI.Providers["default"].APIKey)
|
||||||
}
|
}
|
||||||
if cfg.Server.Port != 9090 {
|
if cfg.Server.Port != 9090 {
|
||||||
t.Fatalf("server port = %d, want 9090", cfg.Server.Port)
|
t.Fatalf("server port = %d, want 9090", cfg.Server.Port)
|
||||||
@@ -90,10 +102,12 @@ logging:
|
|||||||
func TestLoadAppliesOllamaEnvOverrides(t *testing.T) {
|
func TestLoadAppliesOllamaEnvOverrides(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "test.yaml")
|
configPath := filepath.Join(t.TempDir(), "test.yaml")
|
||||||
if err := os.WriteFile(configPath, []byte(`
|
if err := os.WriteFile(configPath, []byte(`
|
||||||
|
version: 2
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
mcp:
|
mcp:
|
||||||
path: "/mcp"
|
path: "/mcp"
|
||||||
|
session_timeout: "10m"
|
||||||
auth:
|
auth:
|
||||||
keys:
|
keys:
|
||||||
- id: "test"
|
- id: "test"
|
||||||
@@ -101,15 +115,20 @@ auth:
|
|||||||
database:
|
database:
|
||||||
url: "postgres://from-file"
|
url: "postgres://from-file"
|
||||||
ai:
|
ai:
|
||||||
provider: "ollama"
|
providers:
|
||||||
|
local:
|
||||||
|
type: "ollama"
|
||||||
|
base_url: "http://localhost:11434/v1"
|
||||||
|
api_key: "ollama"
|
||||||
embeddings:
|
embeddings:
|
||||||
model: "nomic-embed-text"
|
|
||||||
dimensions: 768
|
dimensions: 768
|
||||||
|
primary:
|
||||||
|
provider: "local"
|
||||||
|
model: "nomic-embed-text"
|
||||||
metadata:
|
metadata:
|
||||||
model: "llama3.2"
|
primary:
|
||||||
ollama:
|
provider: "local"
|
||||||
base_url: "http://localhost:11434/v1"
|
model: "llama3.2"
|
||||||
api_key: "ollama"
|
|
||||||
search:
|
search:
|
||||||
default_limit: 10
|
default_limit: 10
|
||||||
max_limit: 50
|
max_limit: 50
|
||||||
@@ -127,10 +146,85 @@ logging:
|
|||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.AI.Ollama.BaseURL != "https://ollama.example.com/v1" {
|
p := cfg.AI.Providers["local"]
|
||||||
t.Fatalf("ollama base url = %q, want env override", cfg.AI.Ollama.BaseURL)
|
if p.BaseURL != "https://ollama.example.com/v1" {
|
||||||
|
t.Fatalf("ollama base url = %q, want env override", p.BaseURL)
|
||||||
}
|
}
|
||||||
if cfg.AI.Ollama.APIKey != "remote-key" {
|
if p.APIKey != "remote-key" {
|
||||||
t.Fatalf("ollama api key = %q, want env override", cfg.AI.Ollama.APIKey)
|
t.Fatalf("ollama api key = %q, want env override", p.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMigratesV1Config(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "v1.yaml")
|
||||||
|
v1 := `
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
mcp:
|
||||||
|
path: "/mcp"
|
||||||
|
session_timeout: "10m"
|
||||||
|
auth:
|
||||||
|
keys:
|
||||||
|
- id: "test"
|
||||||
|
value: "secret"
|
||||||
|
database:
|
||||||
|
url: "postgres://from-file"
|
||||||
|
ai:
|
||||||
|
provider: "litellm"
|
||||||
|
embeddings:
|
||||||
|
model: "text-embed"
|
||||||
|
dimensions: 1536
|
||||||
|
metadata:
|
||||||
|
model: "gpt-4"
|
||||||
|
temperature: 0.2
|
||||||
|
fallback_models: ["gpt-3.5"]
|
||||||
|
litellm:
|
||||||
|
base_url: "http://localhost:4000/v1"
|
||||||
|
api_key: "file-key"
|
||||||
|
search:
|
||||||
|
default_limit: 10
|
||||||
|
max_limit: 50
|
||||||
|
logging:
|
||||||
|
level: "info"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(configPath, []byte(v1), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, _, err := Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Version != CurrentConfigVersion {
|
||||||
|
t.Fatalf("version = %d, want %d", cfg.Version, CurrentConfigVersion)
|
||||||
|
}
|
||||||
|
if p, ok := cfg.AI.Providers["default"]; !ok || p.Type != "litellm" || p.APIKey != "file-key" {
|
||||||
|
t.Fatalf("providers[default] = %+v, want litellm/file-key", p)
|
||||||
|
}
|
||||||
|
if cfg.AI.Embeddings.Primary.Model != "text-embed" || cfg.AI.Embeddings.Primary.Provider != "default" {
|
||||||
|
t.Fatalf("embeddings.primary = %+v, want default/text-embed", cfg.AI.Embeddings.Primary)
|
||||||
|
}
|
||||||
|
if cfg.AI.Metadata.Primary.Model != "gpt-4" || cfg.AI.Metadata.Primary.Provider != "default" {
|
||||||
|
t.Fatalf("metadata.primary = %+v, want default/gpt-4", cfg.AI.Metadata.Primary)
|
||||||
|
}
|
||||||
|
if len(cfg.AI.Metadata.Fallbacks) != 1 || cfg.AI.Metadata.Fallbacks[0].Model != "gpt-3.5" {
|
||||||
|
t.Fatalf("metadata.fallbacks = %+v, want [default/gpt-3.5]", cfg.AI.Metadata.Fallbacks)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := filepath.Glob(configPath + ".bak.*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("glob backups: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("backup files = %d, want 0 (load should not rewrite config)", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
originalOnDisk, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read original config: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(originalOnDisk), "provider: \"litellm\"") {
|
||||||
|
t.Fatalf("expected source config to remain unchanged on disk")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
341
internal/config/migrate.go
Normal file
341
internal/config/migrate.go
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CurrentConfigVersion is the schema version this binary expects. Files at a
|
||||||
|
// lower version are migrated automatically when loaded.
|
||||||
|
const CurrentConfigVersion = 2
|
||||||
|
|
||||||
|
// ConfigMigration upgrades a raw YAML map by one version.
|
||||||
|
type ConfigMigration struct {
|
||||||
|
From, To int
|
||||||
|
Describe string
|
||||||
|
Apply func(map[string]any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrations is the ordered ladder of upgrades. Add new entries at the end.
|
||||||
|
var migrations = []ConfigMigration{
|
||||||
|
{From: 1, To: 2, Describe: "named providers + role chains", Apply: migrateV1toV2},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate brings raw up to CurrentConfigVersion in place. Returns the list of
|
||||||
|
// migrations that were applied (may be empty if already current).
|
||||||
|
func Migrate(raw map[string]any) ([]ConfigMigration, error) {
|
||||||
|
if raw == nil {
|
||||||
|
return nil, fmt.Errorf("migrate: raw config is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
version := readVersion(raw)
|
||||||
|
if version > CurrentConfigVersion {
|
||||||
|
return nil, fmt.Errorf("migrate: config version %d is newer than supported version %d", version, CurrentConfigVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
applied := make([]ConfigMigration, 0)
|
||||||
|
for {
|
||||||
|
if version >= CurrentConfigVersion {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
step, ok := findMigration(version)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("migrate: no migration registered from version %d", version)
|
||||||
|
}
|
||||||
|
if err := step.Apply(raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrate v%d->v%d: %w", step.From, step.To, err)
|
||||||
|
}
|
||||||
|
raw["version"] = step.To
|
||||||
|
version = step.To
|
||||||
|
applied = append(applied, step)
|
||||||
|
}
|
||||||
|
return applied, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMigration(from int) (ConfigMigration, bool) {
|
||||||
|
for _, m := range migrations {
|
||||||
|
if m.From == from {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ConfigMigration{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// readVersion returns the version from raw. Files without a version field are
|
||||||
|
// treated as version 1 (the original schema).
|
||||||
|
func readVersion(raw map[string]any) int {
|
||||||
|
v, ok := raw["version"]
|
||||||
|
if !ok {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
case int64:
|
||||||
|
return int(n)
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateV1toV2 lifts the single-provider config into the named-providers +
|
||||||
|
// role-chains layout. The pre-v2 config implicitly used one provider for both
|
||||||
|
// embeddings and metadata; we materialise that as a provider named "default".
|
||||||
|
func migrateV1toV2(raw map[string]any) error {
|
||||||
|
aiRaw := mapValue(raw, "ai")
|
||||||
|
if aiRaw == nil {
|
||||||
|
aiRaw = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
providerType := stringValue(aiRaw, "provider")
|
||||||
|
if providerType == "" {
|
||||||
|
providerType = "litellm"
|
||||||
|
}
|
||||||
|
|
||||||
|
providers, embeddingModel, metadataModel, fallbackModels := buildV1Provider(aiRaw, providerType)
|
||||||
|
|
||||||
|
embeddingsOld := mapValue(aiRaw, "embeddings")
|
||||||
|
dimensions := intValue(embeddingsOld, "dimensions")
|
||||||
|
if dimensions <= 0 {
|
||||||
|
dimensions = 1536
|
||||||
|
}
|
||||||
|
if embeddingModel == "" {
|
||||||
|
embeddingModel = stringValue(embeddingsOld, "model")
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataOld := mapValue(aiRaw, "metadata")
|
||||||
|
if metadataModel == "" {
|
||||||
|
metadataModel = stringValue(metadataOld, "model")
|
||||||
|
}
|
||||||
|
temperature := floatValue(metadataOld, "temperature")
|
||||||
|
logConversations := boolValue(metadataOld, "log_conversations")
|
||||||
|
timeoutStr := stringValue(metadataOld, "timeout")
|
||||||
|
|
||||||
|
if list := stringListValue(metadataOld, "fallback_models"); len(list) > 0 {
|
||||||
|
fallbackModels = append(fallbackModels, list...)
|
||||||
|
}
|
||||||
|
if v := stringValue(metadataOld, "fallback_model"); v != "" {
|
||||||
|
fallbackModels = append(fallbackModels, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddings := map[string]any{
|
||||||
|
"dimensions": dimensions,
|
||||||
|
"primary": map[string]any{"provider": "default", "model": embeddingModel},
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := map[string]any{
|
||||||
|
"temperature": temperature,
|
||||||
|
"log_conversations": logConversations,
|
||||||
|
"primary": map[string]any{"provider": "default", "model": metadataModel},
|
||||||
|
}
|
||||||
|
if timeoutStr != "" {
|
||||||
|
metadata["timeout"] = timeoutStr
|
||||||
|
}
|
||||||
|
if fallbacks := chainTargets("default", fallbackModels); len(fallbacks) > 0 {
|
||||||
|
metadata["fallbacks"] = fallbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
raw["ai"] = map[string]any{
|
||||||
|
"providers": providers,
|
||||||
|
"embeddings": embeddings,
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildV1Provider(aiRaw map[string]any, providerType string) (map[string]any, string, string, []string) {
|
||||||
|
providers := map[string]any{}
|
||||||
|
defaultEntry := map[string]any{"type": providerType}
|
||||||
|
embedModel := ""
|
||||||
|
metaModel := ""
|
||||||
|
var fallbacks []string
|
||||||
|
|
||||||
|
switch providerType {
|
||||||
|
case "litellm":
|
||||||
|
block := mapValue(aiRaw, "litellm")
|
||||||
|
copyKeys(defaultEntry, block, "base_url", "api_key")
|
||||||
|
copyHeaders(defaultEntry, block, "request_headers")
|
||||||
|
embedModel = stringValue(block, "embedding_model")
|
||||||
|
metaModel = stringValue(block, "metadata_model")
|
||||||
|
if list := stringListValue(block, "fallback_metadata_models"); len(list) > 0 {
|
||||||
|
fallbacks = append(fallbacks, list...)
|
||||||
|
}
|
||||||
|
if v := stringValue(block, "fallback_metadata_model"); v != "" {
|
||||||
|
fallbacks = append(fallbacks, v)
|
||||||
|
}
|
||||||
|
case "ollama":
|
||||||
|
block := mapValue(aiRaw, "ollama")
|
||||||
|
copyKeys(defaultEntry, block, "base_url", "api_key")
|
||||||
|
copyHeaders(defaultEntry, block, "request_headers")
|
||||||
|
case "openrouter":
|
||||||
|
block := mapValue(aiRaw, "openrouter")
|
||||||
|
copyKeys(defaultEntry, block, "base_url", "api_key", "app_name", "site_url")
|
||||||
|
copyHeaders(defaultEntry, block, "extra_headers")
|
||||||
|
// rename: extra_headers → request_headers
|
||||||
|
if hdr, ok := defaultEntry["extra_headers"]; ok {
|
||||||
|
defaultEntry["request_headers"] = hdr
|
||||||
|
delete(defaultEntry, "extra_headers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
providers["default"] = defaultEntry
|
||||||
|
return providers, embedModel, metaModel, fallbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainTargets(provider string, models []string) []any {
|
||||||
|
out := make([]any, 0, len(models))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, m := range models {
|
||||||
|
if m == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := provider + "|" + m
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, map[string]any{"provider": provider, "model": m})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapValue(raw map[string]any, key string) map[string]any {
|
||||||
|
if raw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, ok := raw[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch m := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return m
|
||||||
|
case map[any]any:
|
||||||
|
return convertAnyMap(m)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertAnyMap(in map[any]any) map[string]any {
|
||||||
|
out := make(map[string]any, len(in))
|
||||||
|
keys := make([]string, 0, len(in))
|
||||||
|
for k, v := range in {
|
||||||
|
ks, ok := k.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, ks)
|
||||||
|
out[ks] = v
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(raw map[string]any, key string) string {
|
||||||
|
if raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, ok := raw[key]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func intValue(raw map[string]any, key string) int {
|
||||||
|
if raw == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch n := raw[key].(type) {
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
case int64:
|
||||||
|
return int(n)
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatValue(raw map[string]any, key string) float64 {
|
||||||
|
if raw == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch n := raw[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return n
|
||||||
|
case int:
|
||||||
|
return float64(n)
|
||||||
|
case int64:
|
||||||
|
return float64(n)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolValue(raw map[string]any, key string) bool {
|
||||||
|
if raw == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if b, ok := raw[key].(bool); ok {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringListValue(raw map[string]any, key string) []string {
|
||||||
|
if raw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, ok := raw[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
list, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(list))
|
||||||
|
for _, item := range list {
|
||||||
|
if s, ok := item.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyKeys(dst, src map[string]any, keys ...string) {
|
||||||
|
if src == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := src[k]; ok {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyHeaders(dst, src map[string]any, key string) {
|
||||||
|
if src == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, ok := src[key]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch headers := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if len(headers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst[key] = headers
|
||||||
|
case map[any]any:
|
||||||
|
if len(headers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst[key] = convertAnyMap(headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
internal/config/migrate_test.go
Normal file
77
internal/config/migrate_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMigrateV1ToV2Litellm(t *testing.T) {
|
||||||
|
raw := map[string]any{
|
||||||
|
"ai": map[string]any{
|
||||||
|
"provider": "litellm",
|
||||||
|
"embeddings": map[string]any{
|
||||||
|
"model": "text-embedding-3-small",
|
||||||
|
"dimensions": 1536,
|
||||||
|
},
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"fallback_models": []any{"gpt-4.1-mini"},
|
||||||
|
},
|
||||||
|
"litellm": map[string]any{
|
||||||
|
"base_url": "http://localhost:4000/v1",
|
||||||
|
"api_key": "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
applied, err := Migrate(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Migrate() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(applied) != 1 || applied[0].From != 1 || applied[0].To != 2 {
|
||||||
|
t.Fatalf("applied = %+v, want [v1->v2]", applied)
|
||||||
|
}
|
||||||
|
if got := readVersion(raw); got != CurrentConfigVersion {
|
||||||
|
t.Fatalf("version = %d, want %d", got, CurrentConfigVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
ai := mapValue(raw, "ai")
|
||||||
|
providers := mapValue(ai, "providers")
|
||||||
|
def := mapValue(providers, "default")
|
||||||
|
if got := stringValue(def, "type"); got != "litellm" {
|
||||||
|
t.Fatalf("providers.default.type = %q, want litellm", got)
|
||||||
|
}
|
||||||
|
if got := stringValue(def, "base_url"); got != "http://localhost:4000/v1" {
|
||||||
|
t.Fatalf("providers.default.base_url = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
emb := mapValue(ai, "embeddings")
|
||||||
|
embPrimary := mapValue(emb, "primary")
|
||||||
|
if stringValue(embPrimary, "provider") != "default" || stringValue(embPrimary, "model") != "text-embedding-3-small" {
|
||||||
|
t.Fatalf("embeddings.primary = %+v, want default/text-embedding-3-small", embPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := mapValue(ai, "metadata")
|
||||||
|
metaPrimary := mapValue(meta, "primary")
|
||||||
|
if stringValue(metaPrimary, "provider") != "default" || stringValue(metaPrimary, "model") != "gpt-4o-mini" {
|
||||||
|
t.Fatalf("metadata.primary = %+v, want default/gpt-4o-mini", metaPrimary)
|
||||||
|
}
|
||||||
|
fallbacks, ok := meta["fallbacks"].([]any)
|
||||||
|
if !ok || len(fallbacks) != 1 {
|
||||||
|
t.Fatalf("metadata.fallbacks = %#v, want len=1", meta["fallbacks"])
|
||||||
|
}
|
||||||
|
firstFallback, ok := fallbacks[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("metadata.fallbacks[0] type = %T, want map[string]any", fallbacks[0])
|
||||||
|
}
|
||||||
|
if stringValue(firstFallback, "provider") != "default" || stringValue(firstFallback, "model") != "gpt-4.1-mini" {
|
||||||
|
t.Fatalf("metadata fallback = %+v, want default/gpt-4.1-mini", firstFallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateRejectsNewerVersion(t *testing.T) {
|
||||||
|
raw := map[string]any{"version": CurrentConfigVersion + 1}
|
||||||
|
|
||||||
|
_, err := Migrate(raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Migrate() error = nil, want error for newer config version")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,42 +33,20 @@ func (c Config) Validate() error {
|
|||||||
if strings.TrimSpace(c.MCP.Path) == "" {
|
if strings.TrimSpace(c.MCP.Path) == "" {
|
||||||
return fmt.Errorf("invalid config: mcp.path is required")
|
return fmt.Errorf("invalid config: mcp.path is required")
|
||||||
}
|
}
|
||||||
|
if c.MCP.SSEPath != "" {
|
||||||
|
if strings.TrimSpace(c.MCP.SSEPath) == "" {
|
||||||
|
return fmt.Errorf("invalid config: mcp.sse_path must not be blank whitespace")
|
||||||
|
}
|
||||||
|
if c.MCP.SSEPath == c.MCP.Path {
|
||||||
|
return fmt.Errorf("invalid config: mcp.sse_path %q must differ from mcp.path", c.MCP.SSEPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.MCP.SessionTimeout <= 0 {
|
if c.MCP.SessionTimeout <= 0 {
|
||||||
return fmt.Errorf("invalid config: mcp.session_timeout must be greater than zero")
|
return fmt.Errorf("invalid config: mcp.session_timeout must be greater than zero")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch c.AI.Provider {
|
if err := c.AI.validate(); err != nil {
|
||||||
case "litellm", "ollama", "openrouter":
|
return err
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid config: unsupported ai.provider %q", c.AI.Provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.AI.Embeddings.Dimensions <= 0 {
|
|
||||||
return fmt.Errorf("invalid config: ai.embeddings.dimensions must be greater than zero")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch c.AI.Provider {
|
|
||||||
case "litellm":
|
|
||||||
if strings.TrimSpace(c.AI.LiteLLM.BaseURL) == "" {
|
|
||||||
return fmt.Errorf("invalid config: ai.litellm.base_url is required when ai.provider=litellm")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.AI.LiteLLM.APIKey) == "" {
|
|
||||||
return fmt.Errorf("invalid config: ai.litellm.api_key is required when ai.provider=litellm")
|
|
||||||
}
|
|
||||||
case "ollama":
|
|
||||||
if strings.TrimSpace(c.AI.Ollama.BaseURL) == "" {
|
|
||||||
return fmt.Errorf("invalid config: ai.ollama.base_url is required when ai.provider=ollama")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.AI.Ollama.APIKey) == "" {
|
|
||||||
return fmt.Errorf("invalid config: ai.ollama.api_key is required when ai.provider=ollama")
|
|
||||||
}
|
|
||||||
case "openrouter":
|
|
||||||
if strings.TrimSpace(c.AI.OpenRouter.BaseURL) == "" {
|
|
||||||
return fmt.Errorf("invalid config: ai.openrouter.base_url is required when ai.provider=openrouter")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.AI.OpenRouter.APIKey) == "" {
|
|
||||||
return fmt.Errorf("invalid config: ai.openrouter.api_key is required when ai.provider=openrouter")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Server.Port <= 0 {
|
if c.Server.Port <= 0 {
|
||||||
@@ -100,3 +78,61 @@ func (c Config) Validate() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a AIConfig) validate() error {
|
||||||
|
if len(a.Providers) == 0 {
|
||||||
|
return fmt.Errorf("invalid config: ai.providers must contain at least one entry")
|
||||||
|
}
|
||||||
|
for name, p := range a.Providers {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return fmt.Errorf("invalid config: ai.providers contains an entry with an empty name")
|
||||||
|
}
|
||||||
|
switch p.Type {
|
||||||
|
case "litellm", "ollama", "openrouter":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid config: ai.providers.%s.type %q is not supported", name, p.Type)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.BaseURL) == "" {
|
||||||
|
return fmt.Errorf("invalid config: ai.providers.%s.base_url is required", name)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.APIKey) == "" {
|
||||||
|
return fmt.Errorf("invalid config: ai.providers.%s.api_key is required", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Embeddings.Dimensions <= 0 {
|
||||||
|
return fmt.Errorf("invalid config: ai.embeddings.dimensions must be greater than zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.validateChain("ai.embeddings", a.Embeddings.Chain()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.validateChain("ai.metadata", a.Metadata.Chain()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.Background != nil {
|
||||||
|
if a.Background.Embeddings != nil {
|
||||||
|
if err := a.validateChain("ai.background.embeddings", a.Background.Embeddings.AsTargets()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.Background.Metadata != nil {
|
||||||
|
if err := a.validateChain("ai.background.metadata", a.Background.Metadata.AsTargets()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AIConfig) validateChain(prefix string, chain []RoleTarget) error {
|
||||||
|
if len(chain) == 0 {
|
||||||
|
return fmt.Errorf("invalid config: %s.primary must reference a configured provider and model", prefix)
|
||||||
|
}
|
||||||
|
for i, target := range chain {
|
||||||
|
if _, ok := a.Providers[target.Provider]; !ok {
|
||||||
|
return fmt.Errorf("invalid config: %s[%d] references unknown provider %q", prefix, i, target.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,28 +7,23 @@ import (
|
|||||||
|
|
||||||
func validConfig() Config {
|
func validConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Server: ServerConfig{Port: 8080},
|
Version: CurrentConfigVersion,
|
||||||
MCP: MCPConfig{Path: "/mcp", SessionTimeout: 10 * time.Minute},
|
Server: ServerConfig{Port: 8080},
|
||||||
|
MCP: MCPConfig{Path: "/mcp", SessionTimeout: 10 * time.Minute},
|
||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
Keys: []APIKey{{ID: "test", Value: "secret"}},
|
Keys: []APIKey{{ID: "test", Value: "secret"}},
|
||||||
},
|
},
|
||||||
Database: DatabaseConfig{URL: "postgres://example"},
|
Database: DatabaseConfig{URL: "postgres://example"},
|
||||||
AI: AIConfig{
|
AI: AIConfig{
|
||||||
Provider: "litellm",
|
Providers: map[string]ProviderConfig{
|
||||||
Embeddings: AIEmbeddingConfig{
|
"default": {Type: "litellm", BaseURL: "http://localhost:4000/v1", APIKey: "key"},
|
||||||
|
},
|
||||||
|
Embeddings: EmbeddingsRoleConfig{
|
||||||
Dimensions: 1536,
|
Dimensions: 1536,
|
||||||
|
Primary: RoleTarget{Provider: "default", Model: "text-embed"},
|
||||||
},
|
},
|
||||||
LiteLLM: LiteLLMConfig{
|
Metadata: MetadataRoleConfig{
|
||||||
BaseURL: "http://localhost:4000/v1",
|
Primary: RoleTarget{Provider: "default", Model: "gpt-4"},
|
||||||
APIKey: "key",
|
|
||||||
},
|
|
||||||
Ollama: OllamaConfig{
|
|
||||||
BaseURL: "http://localhost:11434/v1",
|
|
||||||
APIKey: "ollama",
|
|
||||||
},
|
|
||||||
OpenRouter: OpenRouterAIConfig{
|
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
|
||||||
APIKey: "key",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Search: SearchConfig{DefaultLimit: 10, MaxLimit: 50},
|
Search: SearchConfig{DefaultLimit: 10, MaxLimit: 50},
|
||||||
@@ -36,29 +31,44 @@ func validConfig() Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateAcceptsSupportedProviders(t *testing.T) {
|
func TestValidateAcceptsSupportedProviderTypes(t *testing.T) {
|
||||||
cfg := validConfig()
|
for _, providerType := range []string{"litellm", "ollama", "openrouter"} {
|
||||||
if err := cfg.Validate(); err != nil {
|
cfg := validConfig()
|
||||||
t.Fatalf("Validate litellm error = %v", err)
|
p := cfg.AI.Providers["default"]
|
||||||
}
|
p.Type = providerType
|
||||||
|
cfg.AI.Providers["default"] = p
|
||||||
cfg.AI.Provider = "ollama"
|
if err := cfg.Validate(); err != nil {
|
||||||
if err := cfg.Validate(); err != nil {
|
t.Fatalf("Validate %s error = %v", providerType, err)
|
||||||
t.Fatalf("Validate ollama error = %v", err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
cfg.AI.Provider = "openrouter"
|
|
||||||
if err := cfg.Validate(); err != nil {
|
|
||||||
t.Fatalf("Validate openrouter error = %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateRejectsInvalidProvider(t *testing.T) {
|
func TestValidateRejectsInvalidProviderType(t *testing.T) {
|
||||||
cfg := validConfig()
|
cfg := validConfig()
|
||||||
cfg.AI.Provider = "unknown"
|
p := cfg.AI.Providers["default"]
|
||||||
|
p.Type = "unknown"
|
||||||
|
cfg.AI.Providers["default"] = p
|
||||||
|
|
||||||
if err := cfg.Validate(); err == nil {
|
if err := cfg.Validate(); err == nil {
|
||||||
t.Fatal("Validate() error = nil, want error for unsupported provider")
|
t.Fatal("Validate() error = nil, want error for unsupported provider type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsChainWithUnknownProvider(t *testing.T) {
|
||||||
|
cfg := validConfig()
|
||||||
|
cfg.AI.Metadata.Primary = RoleTarget{Provider: "does-not-exist", Model: "x"}
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("Validate() error = nil, want error for chain referencing unknown provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsEmptyProviders(t *testing.T) {
|
||||||
|
cfg := validConfig()
|
||||||
|
cfg.AI.Providers = map[string]ProviderConfig{}
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err == nil {
|
||||||
|
t.Fatal("Validate() error = nil, want error for empty providers")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
internal/generatedmodels/sql_public_activities.go
Normal file
70
internal/generatedmodels/sql_public_activities.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicActivities struct {
|
||||||
|
bun.BaseModel `bun:"table:public.activities,alias:activities"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
ActivityType resolvespec_common.SqlString `bun:"activity_type,type:text,nullzero," json:"activity_type"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
DayOfWeek resolvespec_common.SqlString `bun:"day_of_week,type:text,nullzero," json:"day_of_week"`
|
||||||
|
EndDate resolvespec_common.SqlDate `bun:"end_date,type:date,nullzero," json:"end_date"`
|
||||||
|
EndTime resolvespec_common.SqlTime `bun:"end_time,type:time,nullzero," json:"end_time"`
|
||||||
|
FamilyMemberID resolvespec_common.SqlUUID `bun:"family_member_id,type:uuid,nullzero," json:"family_member_id"`
|
||||||
|
Location resolvespec_common.SqlString `bun:"location,type:text,nullzero," json:"location"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
StartDate resolvespec_common.SqlDate `bun:"start_date,type:date,nullzero," json:"start_date"`
|
||||||
|
StartTime resolvespec_common.SqlTime `bun:"start_time,type:time,nullzero," json:"start_time"`
|
||||||
|
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
|
||||||
|
RelFamilyMemberID *ModelPublicFamilyMembers `bun:"rel:has-one,join:family_member_id=id" json:"relfamilymemberid,omitempty"` // Has one ModelPublicFamilyMembers
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicActivities
|
||||||
|
func (m ModelPublicActivities) TableName() string {
|
||||||
|
return "public.activities"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicActivities
|
||||||
|
func (m ModelPublicActivities) TableNameOnly() string {
|
||||||
|
return "activities"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicActivities
|
||||||
|
func (m ModelPublicActivities) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicActivities) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicActivities) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicActivities) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicActivities) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicActivities) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicActivities) GetPrefix() string {
|
||||||
|
return "ACT"
|
||||||
|
}
|
||||||
66
internal/generatedmodels/sql_public_agent_guardrails.go
Normal file
66
internal/generatedmodels/sql_public_agent_guardrails.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicAgentGuardrails struct {
|
||||||
|
bun.BaseModel `bun:"table:public.agent_guardrails,alias:agent_guardrails"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
|
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
Severity resolvespec_common.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
|
||||||
|
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelGuardrailIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicAgentGuardrails
|
||||||
|
func (m ModelPublicAgentGuardrails) TableName() string {
|
||||||
|
return "public.agent_guardrails"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicAgentGuardrails
|
||||||
|
func (m ModelPublicAgentGuardrails) TableNameOnly() string {
|
||||||
|
return "agent_guardrails"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicAgentGuardrails
|
||||||
|
func (m ModelPublicAgentGuardrails) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicAgentGuardrails) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicAgentGuardrails) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicAgentGuardrails) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicAgentGuardrails) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicAgentGuardrails) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicAgentGuardrails) GetPrefix() string {
|
||||||
|
return "AGG"
|
||||||
|
}
|
||||||
66
internal/generatedmodels/sql_public_agent_skills.go
Normal file
66
internal/generatedmodels/sql_public_agent_skills.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicAgentSkills struct {
|
||||||
|
bun.BaseModel `bun:"table:public.agent_skills,alias:agent_skills"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
|
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelRelatedSkillIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_skill_id" json:"relrelatedskillidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||||
|
RelSkillIDPublicProjectSkills []*ModelPublicProjectSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicprojectskills,omitempty"` // Has many ModelPublicProjectSkills
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicAgentSkills
|
||||||
|
func (m ModelPublicAgentSkills) TableName() string {
|
||||||
|
return "public.agent_skills"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicAgentSkills
|
||||||
|
func (m ModelPublicAgentSkills) TableNameOnly() string {
|
||||||
|
return "agent_skills"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicAgentSkills
|
||||||
|
func (m ModelPublicAgentSkills) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicAgentSkills) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicAgentSkills) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicAgentSkills) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicAgentSkills) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicAgentSkills) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicAgentSkills) GetPrefix() string {
|
||||||
|
return "ASG"
|
||||||
|
}
|
||||||
69
internal/generatedmodels/sql_public_chat_histories.go
Normal file
69
internal/generatedmodels/sql_public_chat_histories.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicChatHistories struct {
|
||||||
|
bun.BaseModel `bun:"table:public.chat_histories,alias:chat_histories"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
AgentID resolvespec_common.SqlString `bun:"agent_id,type:text,nullzero," json:"agent_id"`
|
||||||
|
Channel resolvespec_common.SqlString `bun:"channel,type:text,nullzero," json:"channel"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Messages resolvespec_common.SqlJSONB `bun:"messages,type:jsonb,default:'[',notnull," json:"messages"`
|
||||||
|
Metadata resolvespec_common.SqlJSONB `bun:"metadata,type:jsonb,default:'{}',notnull," json:"metadata"`
|
||||||
|
ProjectID resolvespec_common.SqlUUID `bun:"project_id,type:uuid,nullzero," json:"project_id"`
|
||||||
|
SessionID resolvespec_common.SqlString `bun:"session_id,type:text,notnull," json:"session_id"`
|
||||||
|
Summary resolvespec_common.SqlString `bun:"summary,type:text,nullzero," json:"summary"`
|
||||||
|
Title resolvespec_common.SqlString `bun:"title,type:text,nullzero," json:"title"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=guid" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicChatHistories
|
||||||
|
func (m ModelPublicChatHistories) TableName() string {
|
||||||
|
return "public.chat_histories"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicChatHistories
|
||||||
|
func (m ModelPublicChatHistories) TableNameOnly() string {
|
||||||
|
return "chat_histories"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicChatHistories
|
||||||
|
func (m ModelPublicChatHistories) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicChatHistories) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicChatHistories) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicChatHistories) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicChatHistories) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicChatHistories) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicChatHistories) GetPrefix() string {
|
||||||
|
return "CHH"
|
||||||
|
}
|
||||||
66
internal/generatedmodels/sql_public_contact_interactions.go
Normal file
66
internal/generatedmodels/sql_public_contact_interactions.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicContactInteractions struct {
|
||||||
|
bun.BaseModel `bun:"table:public.contact_interactions,alias:contact_interactions"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
ContactID resolvespec_common.SqlUUID `bun:"contact_id,type:uuid,notnull," json:"contact_id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
FollowUpNeeded bool `bun:"follow_up_needed,type:boolean,default:false,notnull," json:"follow_up_needed"`
|
||||||
|
FollowUpNotes resolvespec_common.SqlString `bun:"follow_up_notes,type:text,nullzero," json:"follow_up_notes"`
|
||||||
|
InteractionType resolvespec_common.SqlString `bun:"interaction_type,type:text,notnull," json:"interaction_type"`
|
||||||
|
OccurredAt resolvespec_common.SqlTimeStamp `bun:"occurred_at,type:timestamptz,default:now(),notnull," json:"occurred_at"`
|
||||||
|
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||||
|
RelContactID *ModelPublicProfessionalContacts `bun:"rel:has-one,join:contact_id=id" json:"relcontactid,omitempty"` // Has one ModelPublicProfessionalContacts
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicContactInteractions
|
||||||
|
func (m ModelPublicContactInteractions) TableName() string {
|
||||||
|
return "public.contact_interactions"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicContactInteractions
|
||||||
|
func (m ModelPublicContactInteractions) TableNameOnly() string {
|
||||||
|
return "contact_interactions"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicContactInteractions
|
||||||
|
func (m ModelPublicContactInteractions) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicContactInteractions) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicContactInteractions) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicContactInteractions) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicContactInteractions) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicContactInteractions) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicContactInteractions) GetPrefix() string {
|
||||||
|
return "CIO"
|
||||||
|
}
|
||||||
66
internal/generatedmodels/sql_public_embeddings.go
Normal file
66
internal/generatedmodels/sql_public_embeddings.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicEmbeddings struct {
|
||||||
|
bun.BaseModel `bun:"table:public.embeddings,alias:embeddings"`
|
||||||
|
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
||||||
|
Dim resolvespec_common.SqlInt32 `bun:"dim,type:int,notnull," json:"dim"`
|
||||||
|
Embedding resolvespec_common.SqlString `bun:"embedding,type:vector,notnull," json:"embedding"`
|
||||||
|
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
|
Model resolvespec_common.SqlString `bun:"model,type:text,notnull,unique:uidx_embeddings_thought_id_model," json:"model"`
|
||||||
|
ThoughtID resolvespec_common.SqlUUID `bun:"thought_id,type:uuid,notnull,unique:uidx_embeddings_thought_id_model," json:"thought_id"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
|
||||||
|
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=guid" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicEmbeddings
|
||||||
|
func (m ModelPublicEmbeddings) TableName() string {
|
||||||
|
return "public.embeddings"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicEmbeddings
|
||||||
|
func (m ModelPublicEmbeddings) TableNameOnly() string {
|
||||||
|
return "embeddings"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicEmbeddings
|
||||||
|
func (m ModelPublicEmbeddings) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicEmbeddings) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicEmbeddings) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicEmbeddings) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicEmbeddings) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicEmbeddings) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicEmbeddings) GetPrefix() string {
|
||||||
|
return "EMB"
|
||||||
|
}
|
||||||
65
internal/generatedmodels/sql_public_family_members.go
Normal file
65
internal/generatedmodels/sql_public_family_members.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicFamilyMembers struct {
|
||||||
|
bun.BaseModel `bun:"table:public.family_members,alias:family_members"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
BirthDate resolvespec_common.SqlDate `bun:"birth_date,type:date,nullzero," json:"birth_date"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
Relationship resolvespec_common.SqlString `bun:"relationship,type:text,nullzero," json:"relationship"`
|
||||||
|
RelFamilyMemberIDPublicActivities []*ModelPublicActivities `bun:"rel:has-many,join:id=family_member_id" json:"relfamilymemberidpublicactivities,omitempty"` // Has many ModelPublicActivities
|
||||||
|
RelFamilyMemberIDPublicImportantDates []*ModelPublicImportantDates `bun:"rel:has-many,join:id=family_member_id" json:"relfamilymemberidpublicimportantdates,omitempty"` // Has many ModelPublicImportantDates
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicFamilyMembers
|
||||||
|
func (m ModelPublicFamilyMembers) TableName() string {
|
||||||
|
return "public.family_members"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicFamilyMembers
|
||||||
|
func (m ModelPublicFamilyMembers) TableNameOnly() string {
|
||||||
|
return "family_members"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicFamilyMembers
|
||||||
|
func (m ModelPublicFamilyMembers) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicFamilyMembers) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicFamilyMembers) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicFamilyMembers) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicFamilyMembers) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicFamilyMembers) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicFamilyMembers) GetPrefix() string {
|
||||||
|
return "FMA"
|
||||||
|
}
|
||||||
65
internal/generatedmodels/sql_public_household_items.go
Normal file
65
internal/generatedmodels/sql_public_household_items.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicHouseholdItems struct {
|
||||||
|
bun.BaseModel `bun:"table:public.household_items,alias:household_items"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
Category resolvespec_common.SqlString `bun:"category,type:text,nullzero," json:"category"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Details resolvespec_common.SqlJSONB `bun:"details,type:jsonb,default:'{}',notnull," json:"details"`
|
||||||
|
Location resolvespec_common.SqlString `bun:"location,type:text,nullzero," json:"location"`
|
||||||
|
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicHouseholdItems
|
||||||
|
func (m ModelPublicHouseholdItems) TableName() string {
|
||||||
|
return "public.household_items"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicHouseholdItems
|
||||||
|
func (m ModelPublicHouseholdItems) TableNameOnly() string {
|
||||||
|
return "household_items"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicHouseholdItems
|
||||||
|
func (m ModelPublicHouseholdItems) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicHouseholdItems) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicHouseholdItems) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicHouseholdItems) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicHouseholdItems) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicHouseholdItems) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicHouseholdItems) GetPrefix() string {
|
||||||
|
return "HIO"
|
||||||
|
}
|
||||||
67
internal/generatedmodels/sql_public_household_vendors.go
Normal file
67
internal/generatedmodels/sql_public_household_vendors.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicHouseholdVendors struct {
|
||||||
|
bun.BaseModel `bun:"table:public.household_vendors,alias:household_vendors"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Email resolvespec_common.SqlString `bun:"email,type:text,nullzero," json:"email"`
|
||||||
|
LastUsed resolvespec_common.SqlDate `bun:"last_used,type:date,nullzero," json:"last_used"`
|
||||||
|
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
Phone resolvespec_common.SqlString `bun:"phone,type:text,nullzero," json:"phone"`
|
||||||
|
Rating resolvespec_common.SqlInt32 `bun:"rating,type:int,nullzero," json:"rating"`
|
||||||
|
ServiceType resolvespec_common.SqlString `bun:"service_type,type:text,nullzero," json:"service_type"`
|
||||||
|
Website resolvespec_common.SqlString `bun:"website,type:text,nullzero," json:"website"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicHouseholdVendors
|
||||||
|
func (m ModelPublicHouseholdVendors) TableName() string {
|
||||||
|
return "public.household_vendors"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicHouseholdVendors
|
||||||
|
func (m ModelPublicHouseholdVendors) TableNameOnly() string {
|
||||||
|
return "household_vendors"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicHouseholdVendors
|
||||||
|
func (m ModelPublicHouseholdVendors) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicHouseholdVendors) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicHouseholdVendors) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicHouseholdVendors) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicHouseholdVendors) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicHouseholdVendors) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicHouseholdVendors) GetPrefix() string {
|
||||||
|
return "HVO"
|
||||||
|
}
|
||||||
66
internal/generatedmodels/sql_public_important_dates.go
Normal file
66
internal/generatedmodels/sql_public_important_dates.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicImportantDates struct {
|
||||||
|
bun.BaseModel `bun:"table:public.important_dates,alias:important_dates"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
DateValue resolvespec_common.SqlDate `bun:"date_value,type:date,notnull," json:"date_value"`
|
||||||
|
FamilyMemberID resolvespec_common.SqlUUID `bun:"family_member_id,type:uuid,nullzero," json:"family_member_id"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
RecurringYearly bool `bun:"recurring_yearly,type:boolean,default:false,notnull," json:"recurring_yearly"`
|
||||||
|
ReminderDaysBefore resolvespec_common.SqlInt32 `bun:"reminder_days_before,type:int,default:7,notnull," json:"reminder_days_before"`
|
||||||
|
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
|
||||||
|
RelFamilyMemberID *ModelPublicFamilyMembers `bun:"rel:has-one,join:family_member_id=id" json:"relfamilymemberid,omitempty"` // Has one ModelPublicFamilyMembers
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicImportantDates
|
||||||
|
func (m ModelPublicImportantDates) TableName() string {
|
||||||
|
return "public.important_dates"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicImportantDates
|
||||||
|
func (m ModelPublicImportantDates) TableNameOnly() string {
|
||||||
|
return "important_dates"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicImportantDates
|
||||||
|
func (m ModelPublicImportantDates) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicImportantDates) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicImportantDates) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicImportantDates) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicImportantDates) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicImportantDates) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicImportantDates) GetPrefix() string {
|
||||||
|
return "IDM"
|
||||||
|
}
|
||||||
83
internal/generatedmodels/sql_public_learnings.go
Normal file
83
internal/generatedmodels/sql_public_learnings.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicLearnings struct {
|
||||||
|
bun.BaseModel `bun:"table:public.learnings,alias:learnings"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
ActionRequired bool `bun:"action_required,type:boolean,default:false,notnull," json:"action_required"`
|
||||||
|
Area resolvespec_common.SqlString `bun:"area,type:text,default:'other',notnull," json:"area"`
|
||||||
|
Category resolvespec_common.SqlString `bun:"category,type:text,default:'insight',notnull," json:"category"`
|
||||||
|
Confidence resolvespec_common.SqlString `bun:"confidence,type:text,default:'hypothesis',notnull," json:"confidence"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Details resolvespec_common.SqlString `bun:"details,type:text,default:'',notnull," json:"details"`
|
||||||
|
DuplicateOfLearningID resolvespec_common.SqlUUID `bun:"duplicate_of_learning_id,type:uuid,nullzero," json:"duplicate_of_learning_id"`
|
||||||
|
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"`
|
||||||
|
ProjectID resolvespec_common.SqlUUID `bun:"project_id,type:uuid,nullzero," json:"project_id"`
|
||||||
|
RelatedSkillID resolvespec_common.SqlUUID `bun:"related_skill_id,type:uuid,nullzero," json:"related_skill_id"`
|
||||||
|
RelatedThoughtID resolvespec_common.SqlUUID `bun:"related_thought_id,type:uuid,nullzero," json:"related_thought_id"`
|
||||||
|
ReviewedAt resolvespec_common.SqlTimeStamp `bun:"reviewed_at,type:timestamptz,nullzero," json:"reviewed_at"`
|
||||||
|
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
|
||||||
|
SourceRef resolvespec_common.SqlString `bun:"source_ref,type:text,nullzero," json:"source_ref"`
|
||||||
|
SourceType resolvespec_common.SqlString `bun:"source_type,type:text,nullzero," json:"source_type"`
|
||||||
|
Status resolvespec_common.SqlString `bun:"status,type:text,default:'pending',notnull," json:"status"`
|
||||||
|
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||||
|
SupersedesLearningID resolvespec_common.SqlUUID `bun:"supersedes_learning_id,type:uuid,nullzero," json:"supersedes_learning_id"`
|
||||||
|
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelDuplicateOfLearningID *ModelPublicLearnings `bun:"rel:has-one,join:duplicate_of_learning_id=id" json:"relduplicateoflearningid,omitempty"` // Has one ModelPublicLearnings
|
||||||
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=guid" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
|
RelRelatedSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:related_skill_id=id" json:"relrelatedskillid,omitempty"` // Has one ModelPublicAgentSkills
|
||||||
|
RelRelatedThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:related_thought_id=guid" json:"relrelatedthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||||
|
RelSupersedesLearningID *ModelPublicLearnings `bun:"rel:has-one,join:supersedes_learning_id=id" json:"relsupersedeslearningid,omitempty"` // Has one ModelPublicLearnings
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicLearnings
|
||||||
|
func (m ModelPublicLearnings) TableName() string {
|
||||||
|
return "public.learnings"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicLearnings
|
||||||
|
func (m ModelPublicLearnings) TableNameOnly() string {
|
||||||
|
return "learnings"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicLearnings
|
||||||
|
func (m ModelPublicLearnings) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicLearnings) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicLearnings) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicLearnings) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicLearnings) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicLearnings) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicLearnings) GetPrefix() string {
|
||||||
|
return "LEA"
|
||||||
|
}
|
||||||
65
internal/generatedmodels/sql_public_maintenance_logs.go
Normal file
65
internal/generatedmodels/sql_public_maintenance_logs.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicMaintenanceLogs struct {
|
||||||
|
bun.BaseModel `bun:"table:public.maintenance_logs,alias:maintenance_logs"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
CompletedAt resolvespec_common.SqlTimeStamp `bun:"completed_at,type:timestamptz,default:now(),notnull," json:"completed_at"`
|
||||||
|
Cost resolvespec_common.SqlFloat64 `bun:"cost,type:decimal(10,2),nullzero," json:"cost"`
|
||||||
|
NextAction resolvespec_common.SqlString `bun:"next_action,type:text,nullzero," json:"next_action"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
PerformedBy resolvespec_common.SqlString `bun:"performed_by,type:text,nullzero," json:"performed_by"`
|
||||||
|
TaskID resolvespec_common.SqlUUID `bun:"task_id,type:uuid,notnull," json:"task_id"`
|
||||||
|
RelTaskID *ModelPublicMaintenanceTasks `bun:"rel:has-one,join:task_id=id" json:"reltaskid,omitempty"` // Has one ModelPublicMaintenanceTasks
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicMaintenanceLogs
|
||||||
|
func (m ModelPublicMaintenanceLogs) TableName() string {
|
||||||
|
return "public.maintenance_logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicMaintenanceLogs
|
||||||
|
func (m ModelPublicMaintenanceLogs) TableNameOnly() string {
|
||||||
|
return "maintenance_logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicMaintenanceLogs
|
||||||
|
func (m ModelPublicMaintenanceLogs) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicMaintenanceLogs) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicMaintenanceLogs) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicMaintenanceLogs) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicMaintenanceLogs) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicMaintenanceLogs) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicMaintenanceLogs) GetPrefix() string {
|
||||||
|
return "MLA"
|
||||||
|
}
|
||||||
68
internal/generatedmodels/sql_public_maintenance_tasks.go
Normal file
68
internal/generatedmodels/sql_public_maintenance_tasks.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicMaintenanceTasks struct {
|
||||||
|
bun.BaseModel `bun:"table:public.maintenance_tasks,alias:maintenance_tasks"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
Category resolvespec_common.SqlString `bun:"category,type:text,nullzero," json:"category"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
FrequencyDays resolvespec_common.SqlInt32 `bun:"frequency_days,type:int,nullzero," json:"frequency_days"`
|
||||||
|
LastCompleted resolvespec_common.SqlTimeStamp `bun:"last_completed,type:timestamptz,nullzero," json:"last_completed"`
|
||||||
|
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
NextDue resolvespec_common.SqlTimeStamp `bun:"next_due,type:timestamptz,nullzero," json:"next_due"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelTaskIDPublicMaintenanceLogs []*ModelPublicMaintenanceLogs `bun:"rel:has-many,join:id=task_id" json:"reltaskidpublicmaintenancelogs,omitempty"` // Has many ModelPublicMaintenanceLogs
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicMaintenanceTasks
|
||||||
|
func (m ModelPublicMaintenanceTasks) TableName() string {
|
||||||
|
return "public.maintenance_tasks"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicMaintenanceTasks
|
||||||
|
func (m ModelPublicMaintenanceTasks) TableNameOnly() string {
|
||||||
|
return "maintenance_tasks"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicMaintenanceTasks
|
||||||
|
func (m ModelPublicMaintenanceTasks) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicMaintenanceTasks) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicMaintenanceTasks) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicMaintenanceTasks) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicMaintenanceTasks) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicMaintenanceTasks) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicMaintenanceTasks) GetPrefix() string {
|
||||||
|
return "MTA"
|
||||||
|
}
|
||||||
67
internal/generatedmodels/sql_public_meal_plans.go
Normal file
67
internal/generatedmodels/sql_public_meal_plans.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicMealPlans struct {
|
||||||
|
bun.BaseModel `bun:"table:public.meal_plans,alias:meal_plans"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
CustomMeal resolvespec_common.SqlString `bun:"custom_meal,type:text,nullzero," json:"custom_meal"`
|
||||||
|
DayOfWeek resolvespec_common.SqlString `bun:"day_of_week,type:text,notnull," json:"day_of_week"`
|
||||||
|
MealType resolvespec_common.SqlString `bun:"meal_type,type:text,notnull," json:"meal_type"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
RecipeID resolvespec_common.SqlUUID `bun:"recipe_id,type:uuid,nullzero," json:"recipe_id"`
|
||||||
|
Servings resolvespec_common.SqlInt32 `bun:"servings,type:int,nullzero," json:"servings"`
|
||||||
|
WeekStart resolvespec_common.SqlDate `bun:"week_start,type:date,notnull," json:"week_start"`
|
||||||
|
RelRecipeID *ModelPublicRecipes `bun:"rel:has-one,join:recipe_id=id" json:"relrecipeid,omitempty"` // Has one ModelPublicRecipes
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicMealPlans
|
||||||
|
func (m ModelPublicMealPlans) TableName() string {
|
||||||
|
return "public.meal_plans"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicMealPlans
|
||||||
|
func (m ModelPublicMealPlans) TableNameOnly() string {
|
||||||
|
return "meal_plans"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicMealPlans
|
||||||
|
func (m ModelPublicMealPlans) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicMealPlans) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicMealPlans) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicMealPlans) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicMealPlans) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicMealPlans) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicMealPlans) GetPrefix() string {
|
||||||
|
return "MPE"
|
||||||
|
}
|
||||||
68
internal/generatedmodels/sql_public_opportunities.go
Normal file
68
internal/generatedmodels/sql_public_opportunities.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicOpportunities struct {
|
||||||
|
bun.BaseModel `bun:"table:public.opportunities,alias:opportunities"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
ContactID resolvespec_common.SqlUUID `bun:"contact_id,type:uuid,nullzero," json:"contact_id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Description resolvespec_common.SqlString `bun:"description,type:text,nullzero," json:"description"`
|
||||||
|
ExpectedCloseDate resolvespec_common.SqlDate `bun:"expected_close_date,type:date,nullzero," json:"expected_close_date"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
Stage resolvespec_common.SqlString `bun:"stage,type:text,default:'identified',notnull," json:"stage"`
|
||||||
|
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
Value resolvespec_common.SqlFloat64 `bun:"value,type:decimal(12,2),nullzero," json:"value"`
|
||||||
|
RelContactID *ModelPublicProfessionalContacts `bun:"rel:has-one,join:contact_id=id" json:"relcontactid,omitempty"` // Has one ModelPublicProfessionalContacts
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicOpportunities
|
||||||
|
func (m ModelPublicOpportunities) TableName() string {
|
||||||
|
return "public.opportunities"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicOpportunities
|
||||||
|
func (m ModelPublicOpportunities) TableNameOnly() string {
|
||||||
|
return "opportunities"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicOpportunities
|
||||||
|
func (m ModelPublicOpportunities) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicOpportunities) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicOpportunities) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicOpportunities) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicOpportunities) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicOpportunities) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicOpportunities) GetPrefix() string {
|
||||||
|
return "OPP"
|
||||||
|
}
|
||||||
73
internal/generatedmodels/sql_public_professional_contacts.go
Normal file
73
internal/generatedmodels/sql_public_professional_contacts.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicProfessionalContacts struct {
|
||||||
|
bun.BaseModel `bun:"table:public.professional_contacts,alias:professional_contacts"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
Company resolvespec_common.SqlString `bun:"company,type:text,nullzero," json:"company"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Email resolvespec_common.SqlString `bun:"email,type:text,nullzero," json:"email"`
|
||||||
|
FollowUpDate resolvespec_common.SqlDate `bun:"follow_up_date,type:date,nullzero," json:"follow_up_date"`
|
||||||
|
HowWeMet resolvespec_common.SqlString `bun:"how_we_met,type:text,nullzero," json:"how_we_met"`
|
||||||
|
LastContacted resolvespec_common.SqlTimeStamp `bun:"last_contacted,type:timestamptz,nullzero," json:"last_contacted"`
|
||||||
|
LinkedinURL resolvespec_common.SqlString `bun:"linkedin_url,type:text,nullzero," json:"linkedin_url"`
|
||||||
|
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
Phone resolvespec_common.SqlString `bun:"phone,type:text,nullzero," json:"phone"`
|
||||||
|
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
|
||||||
|
Title resolvespec_common.SqlString `bun:"title,type:text,nullzero," json:"title"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelContactIDPublicContactInteractions []*ModelPublicContactInteractions `bun:"rel:has-many,join:id=contact_id" json:"relcontactidpubliccontactinteractions,omitempty"` // Has many ModelPublicContactInteractions
|
||||||
|
RelContactIDPublicOpportunities []*ModelPublicOpportunities `bun:"rel:has-many,join:id=contact_id" json:"relcontactidpublicopportunities,omitempty"` // Has many ModelPublicOpportunities
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicProfessionalContacts
|
||||||
|
func (m ModelPublicProfessionalContacts) TableName() string {
|
||||||
|
return "public.professional_contacts"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicProfessionalContacts
|
||||||
|
func (m ModelPublicProfessionalContacts) TableNameOnly() string {
|
||||||
|
return "professional_contacts"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicProfessionalContacts
|
||||||
|
func (m ModelPublicProfessionalContacts) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicProfessionalContacts) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicProfessionalContacts) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicProfessionalContacts) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicProfessionalContacts) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicProfessionalContacts) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicProfessionalContacts) GetPrefix() string {
|
||||||
|
return "PCR"
|
||||||
|
}
|
||||||
63
internal/generatedmodels/sql_public_project_guardrails.go
Normal file
63
internal/generatedmodels/sql_public_project_guardrails.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicProjectGuardrails struct {
|
||||||
|
bun.BaseModel `bun:"table:public.project_guardrails,alias:project_guardrails"`
|
||||||
|
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
GuardrailID resolvespec_common.SqlUUID `bun:"guardrail_id,type:uuid,notnull," json:"guardrail_id"`
|
||||||
|
ProjectID resolvespec_common.SqlUUID `bun:"project_id,type:uuid,notnull," json:"project_id"`
|
||||||
|
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
||||||
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=guid" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicProjectGuardrails
|
||||||
|
func (m ModelPublicProjectGuardrails) TableName() string {
|
||||||
|
return "public.project_guardrails"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicProjectGuardrails
|
||||||
|
func (m ModelPublicProjectGuardrails) TableNameOnly() string {
|
||||||
|
return "project_guardrails"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicProjectGuardrails
|
||||||
|
func (m ModelPublicProjectGuardrails) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicProjectGuardrails) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicProjectGuardrails) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicProjectGuardrails) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicProjectGuardrails) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicProjectGuardrails) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicProjectGuardrails) GetPrefix() string {
|
||||||
|
return "PGR"
|
||||||
|
}
|
||||||
63
internal/generatedmodels/sql_public_project_skills.go
Normal file
63
internal/generatedmodels/sql_public_project_skills.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicProjectSkills struct {
|
||||||
|
bun.BaseModel `bun:"table:public.project_skills,alias:project_skills"`
|
||||||
|
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
ProjectID resolvespec_common.SqlUUID `bun:"project_id,type:uuid,notnull," json:"project_id"`
|
||||||
|
SkillID resolvespec_common.SqlUUID `bun:"skill_id,type:uuid,notnull," json:"skill_id"`
|
||||||
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=guid" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
|
RelSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:skill_id=id" json:"relskillid,omitempty"` // Has one ModelPublicAgentSkills
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicProjectSkills
|
||||||
|
func (m ModelPublicProjectSkills) TableName() string {
|
||||||
|
return "public.project_skills"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicProjectSkills
|
||||||
|
func (m ModelPublicProjectSkills) TableNameOnly() string {
|
||||||
|
return "project_skills"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicProjectSkills
|
||||||
|
func (m ModelPublicProjectSkills) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicProjectSkills) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicProjectSkills) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicProjectSkills) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicProjectSkills) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicProjectSkills) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicProjectSkills) GetPrefix() string {
|
||||||
|
return "PSR"
|
||||||
|
}
|
||||||
70
internal/generatedmodels/sql_public_projects.go
Normal file
70
internal/generatedmodels/sql_public_projects.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicProjects struct {
|
||||||
|
bun.BaseModel `bun:"table:public.projects,alias:projects"`
|
||||||
|
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
||||||
|
Description resolvespec_common.SqlString `bun:"description,type:text,nullzero," json:"description"`
|
||||||
|
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
|
LastActiveAt resolvespec_common.SqlTimeStamp `bun:"last_active_at,type:timestamptz,default:now(),nullzero," json:"last_active_at"`
|
||||||
|
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
ThoughtCount resolvespec_common.SqlInt64 `bun:"thought_count,scanonly" json:"thought_count"`
|
||||||
|
RelProjectIDPublicThoughts []*ModelPublicThoughts `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicthoughts,omitempty"` // Has many ModelPublicThoughts
|
||||||
|
RelProjectIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
||||||
|
RelProjectIDPublicChatHistories []*ModelPublicChatHistories `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicchathistories,omitempty"` // Has many ModelPublicChatHistories
|
||||||
|
RelProjectIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||||
|
RelProjectIDPublicProjectSkills []*ModelPublicProjectSkills `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicprojectskills,omitempty"` // Has many ModelPublicProjectSkills
|
||||||
|
RelProjectIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicProjects
|
||||||
|
func (m ModelPublicProjects) TableName() string {
|
||||||
|
return "public.projects"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicProjects
|
||||||
|
func (m ModelPublicProjects) TableNameOnly() string {
|
||||||
|
return "projects"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicProjects
|
||||||
|
func (m ModelPublicProjects) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicProjects) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicProjects) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicProjects) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicProjects) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicProjects) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicProjects) GetPrefix() string {
|
||||||
|
return "PRO"
|
||||||
|
}
|
||||||
71
internal/generatedmodels/sql_public_recipes.go
Normal file
71
internal/generatedmodels/sql_public_recipes.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicRecipes struct {
|
||||||
|
bun.BaseModel `bun:"table:public.recipes,alias:recipes"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
CookTimeMinutes resolvespec_common.SqlInt32 `bun:"cook_time_minutes,type:int,nullzero," json:"cook_time_minutes"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Cuisine resolvespec_common.SqlString `bun:"cuisine,type:text,nullzero," json:"cuisine"`
|
||||||
|
Ingredients resolvespec_common.SqlJSONB `bun:"ingredients,type:jsonb,default:'[',notnull," json:"ingredients"`
|
||||||
|
Instructions resolvespec_common.SqlJSONB `bun:"instructions,type:jsonb,default:'[',notnull," json:"instructions"`
|
||||||
|
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
PrepTimeMinutes resolvespec_common.SqlInt32 `bun:"prep_time_minutes,type:int,nullzero," json:"prep_time_minutes"`
|
||||||
|
Rating resolvespec_common.SqlInt32 `bun:"rating,type:int,nullzero," json:"rating"`
|
||||||
|
Servings resolvespec_common.SqlInt32 `bun:"servings,type:int,nullzero," json:"servings"`
|
||||||
|
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelRecipeIDPublicMealPlans []*ModelPublicMealPlans `bun:"rel:has-many,join:id=recipe_id" json:"relrecipeidpublicmealplans,omitempty"` // Has many ModelPublicMealPlans
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicRecipes
|
||||||
|
func (m ModelPublicRecipes) TableName() string {
|
||||||
|
return "public.recipes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicRecipes
|
||||||
|
func (m ModelPublicRecipes) TableNameOnly() string {
|
||||||
|
return "recipes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicRecipes
|
||||||
|
func (m ModelPublicRecipes) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicRecipes) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicRecipes) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicRecipes) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicRecipes) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicRecipes) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicRecipes) GetPrefix() string {
|
||||||
|
return "REC"
|
||||||
|
}
|
||||||
63
internal/generatedmodels/sql_public_shopping_lists.go
Normal file
63
internal/generatedmodels/sql_public_shopping_lists.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicShoppingLists struct {
|
||||||
|
bun.BaseModel `bun:"table:public.shopping_lists,alias:shopping_lists"`
|
||||||
|
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Items resolvespec_common.SqlJSONB `bun:"items,type:jsonb,default:'[',notnull," json:"items"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
WeekStart resolvespec_common.SqlDate `bun:"week_start,type:date,notnull," json:"week_start"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicShoppingLists
|
||||||
|
func (m ModelPublicShoppingLists) TableName() string {
|
||||||
|
return "public.shopping_lists"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicShoppingLists
|
||||||
|
func (m ModelPublicShoppingLists) TableNameOnly() string {
|
||||||
|
return "shopping_lists"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicShoppingLists
|
||||||
|
func (m ModelPublicShoppingLists) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicShoppingLists) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicShoppingLists) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicShoppingLists) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicShoppingLists) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicShoppingLists) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicShoppingLists) GetPrefix() string {
|
||||||
|
return "SLH"
|
||||||
|
}
|
||||||
72
internal/generatedmodels/sql_public_stored_files.go
Normal file
72
internal/generatedmodels/sql_public_stored_files.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicStoredFiles struct {
|
||||||
|
bun.BaseModel `bun:"table:public.stored_files,alias:stored_files"`
|
||||||
|
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
|
Content []byte `bun:"content,type:bytea,notnull," json:"content"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Encoding resolvespec_common.SqlString `bun:"encoding,type:text,default:'base64',notnull," json:"encoding"`
|
||||||
|
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
|
Kind resolvespec_common.SqlString `bun:"kind,type:text,default:'file',notnull," json:"kind"`
|
||||||
|
MediaType resolvespec_common.SqlString `bun:"media_type,type:text,notnull," json:"media_type"`
|
||||||
|
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
ProjectID resolvespec_common.SqlUUID `bun:"project_id,type:uuid,nullzero," json:"project_id"`
|
||||||
|
Sha256 resolvespec_common.SqlString `bun:"sha256,type:text,notnull," json:"sha256"`
|
||||||
|
SizeBytes int64 `bun:"size_bytes,type:bigint,notnull," json:"size_bytes"`
|
||||||
|
ThoughtID resolvespec_common.SqlUUID `bun:"thought_id,type:uuid,nullzero," json:"thought_id"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=guid" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
|
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=guid" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicStoredFiles
|
||||||
|
func (m ModelPublicStoredFiles) TableName() string {
|
||||||
|
return "public.stored_files"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicStoredFiles
|
||||||
|
func (m ModelPublicStoredFiles) TableNameOnly() string {
|
||||||
|
return "stored_files"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicStoredFiles
|
||||||
|
func (m ModelPublicStoredFiles) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicStoredFiles) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicStoredFiles) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicStoredFiles) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicStoredFiles) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicStoredFiles) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicStoredFiles) GetPrefix() string {
|
||||||
|
return "SFT"
|
||||||
|
}
|
||||||
64
internal/generatedmodels/sql_public_thought_links.go
Normal file
64
internal/generatedmodels/sql_public_thought_links.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicThoughtLinks struct {
|
||||||
|
bun.BaseModel `bun:"table:public.thought_links,alias:thought_links"`
|
||||||
|
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
||||||
|
FromID int64 `bun:"from_id,type:bigint,notnull," json:"from_id"`
|
||||||
|
Relation resolvespec_common.SqlString `bun:"relation,type:text,notnull," json:"relation"`
|
||||||
|
ToID int64 `bun:"to_id,type:bigint,notnull," json:"to_id"`
|
||||||
|
RelFromID *ModelPublicThoughts `bun:"rel:has-one,join:from_id=id" json:"relfromid,omitempty"` // Has one ModelPublicThoughts
|
||||||
|
RelToID *ModelPublicThoughts `bun:"rel:has-one,join:to_id=id" json:"reltoid,omitempty"` // Has one ModelPublicThoughts
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicThoughtLinks
|
||||||
|
func (m ModelPublicThoughtLinks) TableName() string {
|
||||||
|
return "public.thought_links"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicThoughtLinks
|
||||||
|
func (m ModelPublicThoughtLinks) TableNameOnly() string {
|
||||||
|
return "thought_links"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicThoughtLinks
|
||||||
|
func (m ModelPublicThoughtLinks) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicThoughtLinks) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicThoughtLinks) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicThoughtLinks) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicThoughtLinks) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicThoughtLinks) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicThoughtLinks) GetPrefix() string {
|
||||||
|
return "TLH"
|
||||||
|
}
|
||||||
71
internal/generatedmodels/sql_public_thoughts.go
Normal file
71
internal/generatedmodels/sql_public_thoughts.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicThoughts struct {
|
||||||
|
bun.BaseModel `bun:"table:public.thoughts,alias:thoughts"`
|
||||||
|
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
|
ArchivedAt resolvespec_common.SqlTimeStamp `bun:"archived_at,type:timestamptz,nullzero," json:"archived_at"`
|
||||||
|
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
||||||
|
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
|
Metadata resolvespec_common.SqlJSONB `bun:"metadata,type:jsonb,default:'{}::jsonb',nullzero," json:"metadata"`
|
||||||
|
ProjectID resolvespec_common.SqlUUID `bun:"project_id,type:uuid,nullzero," json:"project_id"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
|
||||||
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=guid" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
|
RelFromIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=from_id" json:"relfromidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
|
||||||
|
RelToIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=to_id" json:"reltoidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
|
||||||
|
RelThoughtIDPublicEmbeddings []*ModelPublicEmbeddings `bun:"rel:has-many,join:guid=thought_id" json:"relthoughtidpublicembeddings,omitempty"` // Has many ModelPublicEmbeddings
|
||||||
|
RelThoughtIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:guid=thought_id" json:"relthoughtidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
||||||
|
RelRelatedThoughtIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:guid=related_thought_id" json:"relrelatedthoughtidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicThoughts
|
||||||
|
func (m ModelPublicThoughts) TableName() string {
|
||||||
|
return "public.thoughts"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicThoughts
|
||||||
|
func (m ModelPublicThoughts) TableNameOnly() string {
|
||||||
|
return "thoughts"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicThoughts
|
||||||
|
func (m ModelPublicThoughts) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicThoughts) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicThoughts) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicThoughts) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicThoughts) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicThoughts) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicThoughts) GetPrefix() string {
|
||||||
|
return "THO"
|
||||||
|
}
|
||||||
62
internal/generatedmodels/sql_public_tool_annotations.go
Normal file
62
internal/generatedmodels/sql_public_tool_annotations.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicToolAnnotations struct {
|
||||||
|
bun.BaseModel `bun:"table:public.tool_annotations,alias:tool_annotations"`
|
||||||
|
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Notes resolvespec_common.SqlString `bun:"notes,type:text,default:'',notnull," json:"notes"`
|
||||||
|
ToolName resolvespec_common.SqlString `bun:"tool_name,type:text,notnull," json:"tool_name"`
|
||||||
|
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicToolAnnotations
|
||||||
|
func (m ModelPublicToolAnnotations) TableName() string {
|
||||||
|
return "public.tool_annotations"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicToolAnnotations
|
||||||
|
func (m ModelPublicToolAnnotations) TableNameOnly() string {
|
||||||
|
return "tool_annotations"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicToolAnnotations
|
||||||
|
func (m ModelPublicToolAnnotations) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicToolAnnotations) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicToolAnnotations) GetIDStr() string {
|
||||||
|
return fmt.Sprintf("%v", m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicToolAnnotations) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicToolAnnotations) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicToolAnnotations) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicToolAnnotations) GetPrefix() string {
|
||||||
|
return "TAO"
|
||||||
|
}
|
||||||
@@ -221,12 +221,19 @@ func formatLogDuration(d time.Duration) string {
|
|||||||
return fmt.Sprintf("%02d:%02d:%03d", minutes, seconds, milliseconds)
|
return fmt.Sprintf("%02d:%02d:%03d", minutes, seconds, milliseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeObjectSchema(schema *jsonschema.Schema) {
|
||||||
|
if schema != nil && schema.Type == "object" && schema.Properties == nil {
|
||||||
|
schema.Properties = map[string]*jsonschema.Schema{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setToolSchemas[In any, Out any](tool *mcp.Tool) error {
|
func setToolSchemas[In any, Out any](tool *mcp.Tool) error {
|
||||||
if tool.InputSchema == nil {
|
if tool.InputSchema == nil {
|
||||||
inputSchema, err := jsonschema.For[In](toolSchemaOptions)
|
inputSchema, err := jsonschema.For[In](toolSchemaOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("infer input schema: %w", err)
|
return fmt.Errorf("infer input schema: %w", err)
|
||||||
}
|
}
|
||||||
|
normalizeObjectSchema(inputSchema)
|
||||||
tool.InputSchema = inputSchema
|
tool.InputSchema = inputSchema
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ import (
|
|||||||
"git.warky.dev/wdevs/amcs/internal/tools"
|
"git.warky.dev/wdevs/amcs/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestSetToolSchemasAddsEmptyPropertiesForNoArgInput(t *testing.T) {
|
||||||
|
type noArgInput struct{}
|
||||||
|
type anyOutput struct{}
|
||||||
|
|
||||||
|
tool := &mcp.Tool{Name: "no_args"}
|
||||||
|
if err := setToolSchemas[noArgInput, anyOutput](tool); err != nil {
|
||||||
|
t.Fatalf("set tool schemas: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema, ok := tool.InputSchema.(*jsonschema.Schema)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("input schema type = %T, want *jsonschema.Schema", tool.InputSchema)
|
||||||
|
}
|
||||||
|
if schema.Properties == nil {
|
||||||
|
t.Fatal("input schema missing properties: strict MCP clients require properties:{} on object schemas")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetToolSchemasUsesStringUUIDsInListOutput(t *testing.T) {
|
func TestSetToolSchemasUsesStringUUIDsInListOutput(t *testing.T) {
|
||||||
tool := &mcp.Tool{Name: "list_thoughts"}
|
tool := &mcp.Tool{Name: "list_thoughts"}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ package mcpserver
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
"git.warky.dev/wdevs/amcs/internal/tools"
|
"git.warky.dev/wdevs/amcs/internal/tools"
|
||||||
|
amcsllm "git.warky.dev/wdevs/amcs/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
serverTitle = "Avalon Memory Crystal Server"
|
||||||
|
serverWebsiteURL = "https://git.warky.dev/wdevs/amcs"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ToolSet struct {
|
type ToolSet struct {
|
||||||
@@ -28,37 +35,64 @@ type ToolSet struct {
|
|||||||
Files *tools.FilesTool
|
Files *tools.FilesTool
|
||||||
Backfill *tools.BackfillTool
|
Backfill *tools.BackfillTool
|
||||||
Reparse *tools.ReparseMetadataTool
|
Reparse *tools.ReparseMetadataTool
|
||||||
RetryMetadata *tools.RetryMetadataTool
|
RetryMetadata *tools.RetryEnrichmentTool
|
||||||
Household *tools.HouseholdTool
|
|
||||||
Maintenance *tools.MaintenanceTool
|
Maintenance *tools.MaintenanceTool
|
||||||
Calendar *tools.CalendarTool
|
|
||||||
Meals *tools.MealsTool
|
|
||||||
CRM *tools.CRMTool
|
|
||||||
Skills *tools.SkillsTool
|
Skills *tools.SkillsTool
|
||||||
ChatHistory *tools.ChatHistoryTool
|
ChatHistory *tools.ChatHistoryTool
|
||||||
|
Describe *tools.DescribeTool
|
||||||
|
Learnings *tools.LearningsTool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handlers groups the HTTP handlers produced for an MCP server instance.
|
||||||
|
type Handlers struct {
|
||||||
|
// StreamableHTTP is the primary MCP handler (always non-nil).
|
||||||
|
StreamableHTTP http.Handler
|
||||||
|
// SSE is the SSE transport handler; nil when SSEPath is empty.
|
||||||
|
// SSE is the de facto transport for MCP over the internet and is required by most hosted MCP clients.
|
||||||
|
SSE http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the StreamableHTTP MCP handler. It is a convenience wrapper
|
||||||
|
// around NewHandlers for callers that only need the primary transport.
|
||||||
func New(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onSessionClosed func(string)) (http.Handler, error) {
|
func New(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onSessionClosed func(string)) (http.Handler, error) {
|
||||||
|
h, err := NewHandlers(cfg, logger, toolSet, onSessionClosed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return h.StreamableHTTP, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandlers builds MCP HTTP handlers for both transports.
|
||||||
|
// SSE is nil when cfg.SSEPath is empty.
|
||||||
|
func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onSessionClosed func(string)) (Handlers, error) {
|
||||||
|
instructions := cfg.Instructions
|
||||||
|
if instructions == "" {
|
||||||
|
instructions = string(amcsllm.MemoryInstructions)
|
||||||
|
}
|
||||||
|
|
||||||
server := mcp.NewServer(&mcp.Implementation{
|
server := mcp.NewServer(&mcp.Implementation{
|
||||||
Name: cfg.ServerName,
|
Name: cfg.ServerName,
|
||||||
Version: cfg.Version,
|
Title: serverTitle,
|
||||||
}, nil)
|
Version: cfg.Version,
|
||||||
|
WebsiteURL: serverWebsiteURL,
|
||||||
|
Icons: buildServerIcons(cfg.PublicURL),
|
||||||
|
}, &mcp.ServerOptions{
|
||||||
|
Instructions: instructions,
|
||||||
|
})
|
||||||
|
|
||||||
for _, register := range []func(*mcp.Server, *slog.Logger, ToolSet) error{
|
for _, register := range []func(*mcp.Server, *slog.Logger, ToolSet) error{
|
||||||
registerSystemTools,
|
registerSystemTools,
|
||||||
registerThoughtTools,
|
registerThoughtTools,
|
||||||
registerProjectTools,
|
registerProjectTools,
|
||||||
|
registerLearningTools,
|
||||||
registerFileTools,
|
registerFileTools,
|
||||||
registerMaintenanceTools,
|
registerMaintenanceTools,
|
||||||
registerHouseholdTools,
|
|
||||||
registerCalendarTools,
|
|
||||||
registerMealTools,
|
|
||||||
registerCRMTools,
|
|
||||||
registerSkillTools,
|
registerSkillTools,
|
||||||
registerChatHistoryTools,
|
registerChatHistoryTools,
|
||||||
|
registerDescribeTools,
|
||||||
} {
|
} {
|
||||||
if err := register(server, logger, toolSet); err != nil {
|
if err := register(server, logger, toolSet); err != nil {
|
||||||
return nil, err
|
return Handlers{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,15 +104,37 @@ func New(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onSessionCl
|
|||||||
opts.EventStore = newCleanupEventStore(mcp.NewMemoryEventStore(nil), onSessionClosed)
|
opts.EventStore = newCleanupEventStore(mcp.NewMemoryEventStore(nil), onSessionClosed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
|
h := Handlers{
|
||||||
return server
|
StreamableHTTP: mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
|
||||||
}, opts), nil
|
return server
|
||||||
|
}, opts),
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(cfg.SSEPath) != "" {
|
||||||
|
h.SSE = mcp.NewSSEHandler(func(*http.Request) *mcp.Server {
|
||||||
|
return server
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServerIcons returns icon definitions referencing the server's own /images/icon.png endpoint.
|
||||||
|
// Returns nil when publicURL is empty so the icons field is omitted from the MCP identity.
|
||||||
|
func buildServerIcons(publicURL string) []mcp.Icon {
|
||||||
|
if strings.TrimSpace(publicURL) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(publicURL, "/")
|
||||||
|
return []mcp.Icon{
|
||||||
|
{Source: base + "/images/icon.png", MIMEType: "image/png"},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerSystemTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
func registerSystemTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "get_version_info",
|
Name: "get_version_info",
|
||||||
Description: "Return the server build version information, including version, tag name, commit, and build date.",
|
Description: "Build version, commit, and date.",
|
||||||
}, toolSet.Version.GetInfo); err != nil {
|
}, toolSet.Version.GetInfo); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -88,13 +144,13 @@ func registerSystemTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSe
|
|||||||
func registerThoughtTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
func registerThoughtTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "capture_thought",
|
Name: "capture_thought",
|
||||||
Description: "Store a thought with generated embeddings and extracted metadata.",
|
Description: "Store a thought; embeddings and metadata extracted async.",
|
||||||
}, toolSet.Capture.Handle); err != nil {
|
}, toolSet.Capture.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "search_thoughts",
|
Name: "search_thoughts",
|
||||||
Description: "Search stored thoughts by semantic similarity.",
|
Description: "Semantic search; falls back to full-text if no embeddings.",
|
||||||
}, toolSet.Search.Handle); err != nil {
|
}, toolSet.Search.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -106,7 +162,7 @@ func registerThoughtTools(server *mcp.Server, logger *slog.Logger, toolSet ToolS
|
|||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "thought_stats",
|
Name: "thought_stats",
|
||||||
Description: "Get counts and top metadata buckets across stored thoughts.",
|
Description: "Counts and top metadata buckets for stored thoughts.",
|
||||||
}, toolSet.Stats.Handle); err != nil {
|
}, toolSet.Stats.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -130,19 +186,19 @@ func registerThoughtTools(server *mcp.Server, logger *slog.Logger, toolSet ToolS
|
|||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "archive_thought",
|
Name: "archive_thought",
|
||||||
Description: "Archive a thought so it is hidden from default search and listing.",
|
Description: "Hide a thought from default search and listing.",
|
||||||
}, toolSet.Archive.Handle); err != nil {
|
}, toolSet.Archive.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "summarize_thoughts",
|
Name: "summarize_thoughts",
|
||||||
Description: "Summarize a filtered or searched set of thoughts.",
|
Description: "LLM summary of a filtered set of thoughts.",
|
||||||
}, toolSet.Summarize.Handle); err != nil {
|
}, toolSet.Summarize.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "recall_context",
|
Name: "recall_context",
|
||||||
Description: "Recall semantically relevant and recent context.",
|
Description: "Semantic + recency context for prompt injection; falls back to full-text.",
|
||||||
}, toolSet.Recall.Handle); err != nil {
|
}, toolSet.Recall.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -154,7 +210,7 @@ func registerThoughtTools(server *mcp.Server, logger *slog.Logger, toolSet ToolS
|
|||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "related_thoughts",
|
Name: "related_thoughts",
|
||||||
Description: "Retrieve explicit links and semantic neighbors for a thought.",
|
Description: "Explicit links and semantic neighbours; falls back to full-text.",
|
||||||
}, toolSet.Links.Related); err != nil {
|
}, toolSet.Links.Related); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -176,25 +232,47 @@ func registerProjectTools(server *mcp.Server, logger *slog.Logger, toolSet ToolS
|
|||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "set_active_project",
|
Name: "set_active_project",
|
||||||
Description: "Set the active project for the current MCP session. Requires a stateful MCP client that reuses the same session across calls.",
|
Description: "Set session's active project. Pass project per call if client is stateless.",
|
||||||
}, toolSet.Projects.SetActive); err != nil {
|
}, toolSet.Projects.SetActive); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "get_active_project",
|
Name: "get_active_project",
|
||||||
Description: "Return the active project for the current MCP session. If your client does not preserve MCP sessions, pass project explicitly to project-scoped tools instead.",
|
Description: "Return session's active project. Pass project per call if client is stateless.",
|
||||||
}, toolSet.Projects.GetActive); err != nil {
|
}, toolSet.Projects.GetActive); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "get_project_context",
|
Name: "get_project_context",
|
||||||
Description: "Get recent and semantic context for a project. Uses the explicit project when provided, otherwise the active MCP session project.",
|
Description: "Recent and semantic context for a project; falls back to full-text.",
|
||||||
}, toolSet.Context.Handle); err != nil {
|
}, toolSet.Context.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerLearningTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "add_learning",
|
||||||
|
Description: "Create a curated learning record distinct from raw thoughts.",
|
||||||
|
}, toolSet.Learnings.Add); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "get_learning",
|
||||||
|
Description: "Retrieve a structured learning by id.",
|
||||||
|
}, toolSet.Learnings.Get); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "list_learnings",
|
||||||
|
Description: "List structured learnings with optional project, status, priority, tag, and text filters.",
|
||||||
|
}, toolSet.Learnings.List); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func registerFileTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
func registerFileTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
server.AddResourceTemplate(&mcp.ResourceTemplate{
|
server.AddResourceTemplate(&mcp.ResourceTemplate{
|
||||||
Name: "stored_file",
|
Name: "stored_file",
|
||||||
@@ -204,19 +282,19 @@ func registerFileTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet)
|
|||||||
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "upload_file",
|
Name: "upload_file",
|
||||||
Description: "Stage a file and get an amcs://files/{id} resource URI. Provide content_path (absolute server-side path, no size limit) or content_base64 (≤10 MB). Optionally link immediately with thought_id/project, or omit them and pass the returned URI to save_file later.",
|
Description: "Stage a file; returns amcs://files/{id}. content_path for large/binary, content_base64 for ≤10 MB. Link now or pass URI to save_file.",
|
||||||
}, toolSet.Files.Upload); err != nil {
|
}, toolSet.Files.Upload); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "save_file",
|
Name: "save_file",
|
||||||
Description: "Store a file and optionally link it to a thought. Supply either content_base64 (≤10 MB) or content_uri (amcs://files/{id} from a prior upload_file or POST /files call). For files larger than 10 MB, use upload_file with content_path first.",
|
Description: "Store and optionally link a file. content_base64 (≤10 MB) or content_uri from upload_file. >10 MB: use upload_file first.",
|
||||||
}, toolSet.Files.Save); err != nil {
|
}, toolSet.Files.Save); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "load_file",
|
Name: "load_file",
|
||||||
Description: "Load a previously stored file by id and return its metadata and base64 content.",
|
Description: "Fetch file metadata and content by id (UUID or amcs://files/{id}); includes embedded MCP resource.",
|
||||||
}, toolSet.Files.Load); err != nil {
|
}, toolSet.Files.Load); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -232,19 +310,19 @@ func registerFileTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet)
|
|||||||
func registerMaintenanceTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
func registerMaintenanceTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "backfill_embeddings",
|
Name: "backfill_embeddings",
|
||||||
Description: "Generate missing embeddings for stored thoughts using the active embedding model.",
|
Description: "Generate missing embeddings. Run after model switch or bulk import.",
|
||||||
}, toolSet.Backfill.Handle); err != nil {
|
}, toolSet.Backfill.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "reparse_thought_metadata",
|
Name: "reparse_thought_metadata",
|
||||||
Description: "Re-extract and normalize metadata for stored thoughts from their content.",
|
Description: "Re-extract metadata from thought content.",
|
||||||
}, toolSet.Reparse.Handle); err != nil {
|
}, toolSet.Reparse.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "retry_failed_metadata",
|
Name: "retry_failed_metadata",
|
||||||
Description: "Retry metadata extraction for thoughts still marked pending or failed.",
|
Description: "Retry pending/failed metadata extraction.",
|
||||||
}, toolSet.RetryMetadata.Handle); err != nil {
|
}, toolSet.RetryMetadata.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -256,7 +334,7 @@ func registerMaintenanceTools(server *mcp.Server, logger *slog.Logger, toolSet T
|
|||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "log_maintenance",
|
Name: "log_maintenance",
|
||||||
Description: "Log completed maintenance work; automatically updates the task's next due date.",
|
Description: "Log completed maintenance; updates next due date.",
|
||||||
}, toolSet.Maintenance.LogWork); err != nil {
|
}, toolSet.Maintenance.LogWork); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -275,176 +353,10 @@ func registerMaintenanceTools(server *mcp.Server, logger *slog.Logger, toolSet T
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerHouseholdTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "add_household_item",
|
|
||||||
Description: "Store a household fact (paint color, appliance details, measurement, document, etc.).",
|
|
||||||
}, toolSet.Household.AddItem); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "search_household_items",
|
|
||||||
Description: "Search household items by name, category, or location.",
|
|
||||||
}, toolSet.Household.SearchItems); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "get_household_item",
|
|
||||||
Description: "Retrieve a household item by id.",
|
|
||||||
}, toolSet.Household.GetItem); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "add_vendor",
|
|
||||||
Description: "Add a service provider (plumber, electrician, landscaper, etc.).",
|
|
||||||
}, toolSet.Household.AddVendor); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "list_vendors",
|
|
||||||
Description: "List household service vendors, optionally filtered by service type.",
|
|
||||||
}, toolSet.Household.ListVendors); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerCalendarTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "add_family_member",
|
|
||||||
Description: "Add a family member to the household.",
|
|
||||||
}, toolSet.Calendar.AddMember); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "list_family_members",
|
|
||||||
Description: "List all family members.",
|
|
||||||
}, toolSet.Calendar.ListMembers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "add_activity",
|
|
||||||
Description: "Schedule a one-time or recurring family activity.",
|
|
||||||
}, toolSet.Calendar.AddActivity); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "get_week_schedule",
|
|
||||||
Description: "Get all activities scheduled for a given week.",
|
|
||||||
}, toolSet.Calendar.GetWeekSchedule); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "search_activities",
|
|
||||||
Description: "Search activities by title, type, or family member.",
|
|
||||||
}, toolSet.Calendar.SearchActivities); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "add_important_date",
|
|
||||||
Description: "Track a birthday, anniversary, deadline, or other important date.",
|
|
||||||
}, toolSet.Calendar.AddImportantDate); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "get_upcoming_dates",
|
|
||||||
Description: "Get important dates coming up in the next N days.",
|
|
||||||
}, toolSet.Calendar.GetUpcomingDates); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerMealTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "add_recipe",
|
|
||||||
Description: "Save a recipe with ingredients and instructions.",
|
|
||||||
}, toolSet.Meals.AddRecipe); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "search_recipes",
|
|
||||||
Description: "Search recipes by name, cuisine, tags, or ingredient.",
|
|
||||||
}, toolSet.Meals.SearchRecipes); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "update_recipe",
|
|
||||||
Description: "Update an existing recipe.",
|
|
||||||
}, toolSet.Meals.UpdateRecipe); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "create_meal_plan",
|
|
||||||
Description: "Set the meal plan for a week; replaces any existing plan for that week.",
|
|
||||||
}, toolSet.Meals.CreateMealPlan); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "get_meal_plan",
|
|
||||||
Description: "Get the meal plan for a given week.",
|
|
||||||
}, toolSet.Meals.GetMealPlan); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "generate_shopping_list",
|
|
||||||
Description: "Auto-generate a shopping list from the meal plan for a given week.",
|
|
||||||
}, toolSet.Meals.GenerateShoppingList); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerCRMTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "add_professional_contact",
|
|
||||||
Description: "Add a professional contact to the CRM.",
|
|
||||||
}, toolSet.CRM.AddContact); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "search_contacts",
|
|
||||||
Description: "Search professional contacts by name, company, title, notes, or tags.",
|
|
||||||
}, toolSet.CRM.SearchContacts); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "log_interaction",
|
|
||||||
Description: "Log an interaction with a professional contact.",
|
|
||||||
}, toolSet.CRM.LogInteraction); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "get_contact_history",
|
|
||||||
Description: "Get full history (interactions and opportunities) for a contact.",
|
|
||||||
}, toolSet.CRM.GetHistory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "create_opportunity",
|
|
||||||
Description: "Create a deal, project, or opportunity linked to a contact.",
|
|
||||||
}, toolSet.CRM.CreateOpportunity); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "get_follow_ups_due",
|
|
||||||
Description: "List contacts with a follow-up date due within the next N days.",
|
|
||||||
}, toolSet.CRM.GetFollowUpsDue); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
|
||||||
Name: "link_thought_to_contact",
|
|
||||||
Description: "Append a stored thought to a contact's notes.",
|
|
||||||
}, toolSet.CRM.LinkThought); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerSkillTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
func registerSkillTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "add_skill",
|
Name: "add_skill",
|
||||||
Description: "Store a reusable agent skill (behavioural instruction or capability prompt).",
|
Description: "Store an agent skill (instruction or capability prompt).",
|
||||||
}, toolSet.Skills.AddSkill); err != nil {
|
}, toolSet.Skills.AddSkill); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -462,7 +374,7 @@ func registerSkillTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet
|
|||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "add_guardrail",
|
Name: "add_guardrail",
|
||||||
Description: "Store a reusable agent guardrail (constraint or safety rule).",
|
Description: "Store an agent guardrail (constraint or safety rule).",
|
||||||
}, toolSet.Skills.AddGuardrail); err != nil {
|
}, toolSet.Skills.AddGuardrail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -480,37 +392,37 @@ func registerSkillTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet
|
|||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "add_project_skill",
|
Name: "add_project_skill",
|
||||||
Description: "Link an agent skill to a project. Pass project explicitly when your client does not preserve MCP sessions.",
|
Description: "Link a skill to a project. Pass project if client is stateless.",
|
||||||
}, toolSet.Skills.AddProjectSkill); err != nil {
|
}, toolSet.Skills.AddProjectSkill); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "remove_project_skill",
|
Name: "remove_project_skill",
|
||||||
Description: "Unlink an agent skill from a project. Pass project explicitly when your client does not preserve MCP sessions.",
|
Description: "Unlink a skill from a project. Pass project if client is stateless.",
|
||||||
}, toolSet.Skills.RemoveProjectSkill); err != nil {
|
}, toolSet.Skills.RemoveProjectSkill); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "list_project_skills",
|
Name: "list_project_skills",
|
||||||
Description: "List all skills linked to a project. Call this at the start of a project session to load existing agent behaviour instructions before generating new ones. Pass project explicitly when your client does not preserve MCP sessions.",
|
Description: "Skills for a project. Load at session start; only add new if none returned. Pass project if stateless.",
|
||||||
}, toolSet.Skills.ListProjectSkills); err != nil {
|
}, toolSet.Skills.ListProjectSkills); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "add_project_guardrail",
|
Name: "add_project_guardrail",
|
||||||
Description: "Link an agent guardrail to a project. Pass project explicitly when your client does not preserve MCP sessions.",
|
Description: "Link a guardrail to a project. Pass project if client is stateless.",
|
||||||
}, toolSet.Skills.AddProjectGuardrail); err != nil {
|
}, toolSet.Skills.AddProjectGuardrail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "remove_project_guardrail",
|
Name: "remove_project_guardrail",
|
||||||
Description: "Unlink an agent guardrail from a project. Pass project explicitly when your client does not preserve MCP sessions.",
|
Description: "Unlink a guardrail from a project. Pass project if client is stateless.",
|
||||||
}, toolSet.Skills.RemoveProjectGuardrail); err != nil {
|
}, toolSet.Skills.RemoveProjectGuardrail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "list_project_guardrails",
|
Name: "list_project_guardrails",
|
||||||
Description: "List all guardrails linked to a project. Call this at the start of a project session to load existing agent constraints before generating new ones. Pass project explicitly when your client does not preserve MCP sessions.",
|
Description: "Guardrails for a project. Load at session start; only add new if none returned. Pass project if stateless.",
|
||||||
}, toolSet.Skills.ListProjectGuardrails); err != nil {
|
}, toolSet.Skills.ListProjectGuardrails); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -520,27 +432,119 @@ func registerSkillTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet
|
|||||||
func registerChatHistoryTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
func registerChatHistoryTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "save_chat_history",
|
Name: "save_chat_history",
|
||||||
Description: "Save a chat session's message history for later retrieval. Stores messages with optional title, summary, channel, agent, and project metadata.",
|
Description: "Save chat messages with optional title, summary, channel, agent, and project.",
|
||||||
}, toolSet.ChatHistory.SaveChatHistory); err != nil {
|
}, toolSet.ChatHistory.SaveChatHistory); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "get_chat_history",
|
Name: "get_chat_history",
|
||||||
Description: "Retrieve a saved chat history by its UUID or session_id. Returns the full message list.",
|
Description: "Fetch chat history by UUID or session_id.",
|
||||||
}, toolSet.ChatHistory.GetChatHistory); err != nil {
|
}, toolSet.ChatHistory.GetChatHistory); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "list_chat_histories",
|
Name: "list_chat_histories",
|
||||||
Description: "List saved chat histories with optional filters: project, channel, agent_id, session_id, or recent days.",
|
Description: "List chat histories; filter by project, channel, agent_id, session_id, or days.",
|
||||||
}, toolSet.ChatHistory.ListChatHistories); err != nil {
|
}, toolSet.ChatHistory.ListChatHistories); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "delete_chat_history",
|
Name: "delete_chat_history",
|
||||||
Description: "Permanently delete a saved chat history by id.",
|
Description: "Delete a chat history by id.",
|
||||||
}, toolSet.ChatHistory.DeleteChatHistory); err != nil {
|
}, toolSet.ChatHistory.DeleteChatHistory); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerDescribeTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "describe_tools",
|
||||||
|
Description: "Call first each session. All tools with categories and usage notes. Categories: system, thoughts, projects, files, admin, maintenance, skills, chat, meta.",
|
||||||
|
}, toolSet.Describe.Describe); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "annotate_tool",
|
||||||
|
Description: "Save usage notes for a tool; returned by describe_tools. Empty string clears.",
|
||||||
|
}, toolSet.Describe.Annotate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildToolCatalog returns the static catalog of all registered MCP tools.
|
||||||
|
// Pass this to tools.NewDescribeTool when assembling the ToolSet.
|
||||||
|
func BuildToolCatalog() []tools.ToolEntry {
|
||||||
|
return []tools.ToolEntry{
|
||||||
|
// system
|
||||||
|
{Name: "get_version_info", Description: "Return the server build version information, including version, tag name, commit, and build date.", Category: "system"},
|
||||||
|
|
||||||
|
// thoughts
|
||||||
|
{Name: "capture_thought", Description: "Store a thought with generated embeddings and extracted metadata. The thought is saved immediately even if metadata extraction times out; pending thoughts are retried in the background.", Category: "thoughts"},
|
||||||
|
{Name: "search_thoughts", Description: "Search stored thoughts by semantic similarity. Falls back to Postgres full-text search automatically when no embeddings exist for the active model.", Category: "thoughts"},
|
||||||
|
{Name: "list_thoughts", Description: "List recent thoughts with optional metadata filters.", Category: "thoughts"},
|
||||||
|
{Name: "thought_stats", Description: "Get counts and top metadata buckets across stored thoughts.", Category: "thoughts"},
|
||||||
|
{Name: "get_thought", Description: "Retrieve a full thought by id.", Category: "thoughts"},
|
||||||
|
{Name: "update_thought", Description: "Update thought content or merge metadata.", Category: "thoughts"},
|
||||||
|
{Name: "delete_thought", Description: "Hard-delete a thought by id.", Category: "thoughts"},
|
||||||
|
{Name: "archive_thought", Description: "Archive a thought so it is hidden from default search and listing.", Category: "thoughts"},
|
||||||
|
{Name: "summarize_thoughts", Description: "Produce an LLM prose summary of a filtered or searched set of thoughts.", Category: "thoughts"},
|
||||||
|
{Name: "recall_context", Description: "Recall semantically relevant and recent context for prompt injection. Combines vector similarity with recency. Falls back to full-text search when no embeddings exist.", Category: "thoughts"},
|
||||||
|
{Name: "link_thoughts", Description: "Create a typed relationship between two thoughts.", Category: "thoughts"},
|
||||||
|
{Name: "related_thoughts", Description: "Retrieve explicit links and semantic neighbours for a thought. Falls back to full-text search when no embeddings exist.", Category: "thoughts"},
|
||||||
|
|
||||||
|
// projects
|
||||||
|
{Name: "create_project", Description: "Create a named project container for thoughts.", Category: "projects"},
|
||||||
|
{Name: "list_projects", Description: "List projects and their current thought counts.", Category: "projects"},
|
||||||
|
{Name: "set_active_project", Description: "Set the active project for the current MCP session. Requires a stateful MCP client that reuses the same session across calls. If your client does not preserve sessions, pass project explicitly to each tool instead.", Category: "projects"},
|
||||||
|
{Name: "get_active_project", Description: "Return the active project for the current MCP session. If your client does not preserve MCP sessions, pass project explicitly to project-scoped tools instead of relying on this.", Category: "projects"},
|
||||||
|
{Name: "get_project_context", Description: "Get recent and semantic context for a project. Uses the explicit project when provided, otherwise the active MCP session project. Falls back to full-text search when no embeddings exist.", Category: "projects"},
|
||||||
|
|
||||||
|
// learnings
|
||||||
|
{Name: "add_learning", Description: "Create a curated learning record distinct from raw thoughts.", Category: "projects"},
|
||||||
|
{Name: "get_learning", Description: "Retrieve a structured learning by id.", Category: "projects"},
|
||||||
|
{Name: "list_learnings", Description: "List structured learnings with optional project, category, area, status, priority, tag, and text filters.", Category: "projects"},
|
||||||
|
|
||||||
|
// files
|
||||||
|
{Name: "upload_file", Description: "Stage a file and get an amcs://files/{id} resource URI. Use content_path (absolute server-side path, no size limit) for large or binary files, or content_base64 (≤10 MB) for small files. Pass thought_id/project to link immediately, or omit and pass the URI to save_file later.", Category: "files"},
|
||||||
|
{Name: "save_file", Description: "Store a file and optionally link it to a thought. Use content_base64 (≤10 MB) for small files, or content_uri (amcs://files/{id} from a prior upload_file) for previously staged files. For files larger than 10 MB, use upload_file with content_path first. If the goal is to retain the artifact, store the file directly instead of reading or summarising it first.", Category: "files"},
|
||||||
|
{Name: "load_file", Description: "Load a stored file by id. Returns metadata, base64 content, and an embedded MCP binary resource at amcs://files/{id}. Prefer the embedded resource when your client supports it. The id field accepts a bare UUID or full amcs://files/{id} URI.", Category: "files"},
|
||||||
|
{Name: "list_files", Description: "List stored files, optionally filtered by thought, project, or kind.", Category: "files"},
|
||||||
|
|
||||||
|
// admin
|
||||||
|
{Name: "backfill_embeddings", Description: "Generate missing embeddings for stored thoughts using the active embedding model. Run this after switching embedding models or importing thoughts that have no vectors.", Category: "admin"},
|
||||||
|
{Name: "reparse_thought_metadata", Description: "Re-extract and normalize metadata for stored thoughts from their content.", Category: "admin"},
|
||||||
|
{Name: "retry_failed_metadata", Description: "Retry metadata extraction for thoughts still marked pending or failed.", Category: "admin"},
|
||||||
|
|
||||||
|
// maintenance
|
||||||
|
{Name: "add_maintenance_task", Description: "Create a recurring or one-time home maintenance task.", Category: "maintenance"},
|
||||||
|
{Name: "log_maintenance", Description: "Log completed maintenance work; automatically updates the task's next due date.", Category: "maintenance"},
|
||||||
|
{Name: "get_upcoming_maintenance", Description: "List maintenance tasks due within the next N days.", Category: "maintenance"},
|
||||||
|
{Name: "search_maintenance_history", Description: "Search the maintenance log by task name, category, or date range.", Category: "maintenance"},
|
||||||
|
|
||||||
|
// skills
|
||||||
|
{Name: "add_skill", Description: "Store a reusable agent skill (behavioural instruction or capability prompt).", Category: "skills"},
|
||||||
|
{Name: "remove_skill", Description: "Delete an agent skill by id.", Category: "skills"},
|
||||||
|
{Name: "list_skills", Description: "List all agent skills, optionally filtered by tag.", Category: "skills"},
|
||||||
|
{Name: "add_guardrail", Description: "Store a reusable agent guardrail (constraint or safety rule).", Category: "skills"},
|
||||||
|
{Name: "remove_guardrail", Description: "Delete an agent guardrail by id.", Category: "skills"},
|
||||||
|
{Name: "list_guardrails", Description: "List all agent guardrails, optionally filtered by tag or severity.", Category: "skills"},
|
||||||
|
{Name: "add_project_skill", Description: "Link an agent skill to a project. Pass project explicitly when your client does not preserve MCP sessions.", Category: "skills"},
|
||||||
|
{Name: "remove_project_skill", Description: "Unlink an agent skill from a project. Pass project explicitly when your client does not preserve MCP sessions.", Category: "skills"},
|
||||||
|
{Name: "list_project_skills", Description: "List all skills linked to a project. Call this at the start of every project session to load agent behaviour instructions before generating new ones. Only create new skills if none are returned. Pass project explicitly when your client does not preserve MCP sessions.", Category: "skills"},
|
||||||
|
{Name: "add_project_guardrail", Description: "Link an agent guardrail to a project. Pass project explicitly when your client does not preserve MCP sessions.", Category: "skills"},
|
||||||
|
{Name: "remove_project_guardrail", Description: "Unlink an agent guardrail from a project. Pass project explicitly when your client does not preserve MCP sessions.", Category: "skills"},
|
||||||
|
{Name: "list_project_guardrails", Description: "List all guardrails linked to a project. Call this at the start of every project session to load agent constraints before generating new ones. Only create new guardrails if none are returned. Pass project explicitly when your client does not preserve MCP sessions.", Category: "skills"},
|
||||||
|
|
||||||
|
// chat
|
||||||
|
{Name: "save_chat_history", Description: "Save a chat session's message history for later retrieval. Stores messages with optional title, summary, channel, agent, and project metadata.", Category: "chat"},
|
||||||
|
{Name: "get_chat_history", Description: "Retrieve a saved chat history by its UUID or session_id. Returns the full message list.", Category: "chat"},
|
||||||
|
{Name: "list_chat_histories", Description: "List saved chat histories with optional filters: project, channel, agent_id, session_id, or recent days.", Category: "chat"},
|
||||||
|
{Name: "delete_chat_history", Description: "Permanently delete a saved chat history by id.", Category: "chat"},
|
||||||
|
|
||||||
|
// meta
|
||||||
|
{Name: "describe_tools", Description: "Call this first in every session. Returns all available MCP tools with names, descriptions, categories, and your accumulated usage notes. Filter by category to narrow results. Available categories: system, thoughts, projects, files, admin, household, maintenance, calendar, meals, crm, skills, chat, meta.", Category: "meta"},
|
||||||
|
{Name: "annotate_tool", Description: "Persist usage notes, gotchas, or workflow patterns for a specific tool. Notes survive across sessions and are returned by describe_tools. Call this whenever you discover something non-obvious about a tool's behaviour. Pass an empty string to clear notes.", Category: "meta"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,50 +28,38 @@ func TestNewListsAllRegisteredTools(t *testing.T) {
|
|||||||
sort.Strings(got)
|
sort.Strings(got)
|
||||||
|
|
||||||
want := []string{
|
want := []string{
|
||||||
"add_activity",
|
|
||||||
"add_family_member",
|
|
||||||
"add_guardrail",
|
"add_guardrail",
|
||||||
"add_household_item",
|
"add_learning",
|
||||||
"add_important_date",
|
|
||||||
"add_maintenance_task",
|
"add_maintenance_task",
|
||||||
"add_professional_contact",
|
|
||||||
"add_project_guardrail",
|
"add_project_guardrail",
|
||||||
"add_project_skill",
|
"add_project_skill",
|
||||||
"add_recipe",
|
|
||||||
"add_skill",
|
"add_skill",
|
||||||
"add_vendor",
|
"annotate_tool",
|
||||||
"archive_thought",
|
"archive_thought",
|
||||||
"backfill_embeddings",
|
"backfill_embeddings",
|
||||||
"capture_thought",
|
"capture_thought",
|
||||||
"create_meal_plan",
|
|
||||||
"create_opportunity",
|
|
||||||
"create_project",
|
"create_project",
|
||||||
|
"delete_chat_history",
|
||||||
"delete_thought",
|
"delete_thought",
|
||||||
"generate_shopping_list",
|
"describe_tools",
|
||||||
"get_active_project",
|
"get_active_project",
|
||||||
"get_contact_history",
|
"get_chat_history",
|
||||||
"get_follow_ups_due",
|
"get_learning",
|
||||||
"get_household_item",
|
|
||||||
"get_meal_plan",
|
|
||||||
"get_project_context",
|
"get_project_context",
|
||||||
"get_thought",
|
"get_thought",
|
||||||
"get_upcoming_dates",
|
|
||||||
"get_upcoming_maintenance",
|
"get_upcoming_maintenance",
|
||||||
"get_version_info",
|
"get_version_info",
|
||||||
"get_week_schedule",
|
|
||||||
"link_thought_to_contact",
|
|
||||||
"link_thoughts",
|
"link_thoughts",
|
||||||
"list_family_members",
|
"list_chat_histories",
|
||||||
"list_files",
|
"list_files",
|
||||||
"list_guardrails",
|
"list_guardrails",
|
||||||
|
"list_learnings",
|
||||||
"list_project_guardrails",
|
"list_project_guardrails",
|
||||||
"list_project_skills",
|
"list_project_skills",
|
||||||
"list_projects",
|
"list_projects",
|
||||||
"list_skills",
|
"list_skills",
|
||||||
"list_thoughts",
|
"list_thoughts",
|
||||||
"list_vendors",
|
|
||||||
"load_file",
|
"load_file",
|
||||||
"log_interaction",
|
|
||||||
"log_maintenance",
|
"log_maintenance",
|
||||||
"recall_context",
|
"recall_context",
|
||||||
"related_thoughts",
|
"related_thoughts",
|
||||||
@@ -81,17 +69,13 @@ func TestNewListsAllRegisteredTools(t *testing.T) {
|
|||||||
"remove_skill",
|
"remove_skill",
|
||||||
"reparse_thought_metadata",
|
"reparse_thought_metadata",
|
||||||
"retry_failed_metadata",
|
"retry_failed_metadata",
|
||||||
|
"save_chat_history",
|
||||||
"save_file",
|
"save_file",
|
||||||
"search_activities",
|
|
||||||
"search_contacts",
|
|
||||||
"search_household_items",
|
|
||||||
"search_maintenance_history",
|
"search_maintenance_history",
|
||||||
"search_recipes",
|
|
||||||
"search_thoughts",
|
"search_thoughts",
|
||||||
"set_active_project",
|
"set_active_project",
|
||||||
"summarize_thoughts",
|
"summarize_thoughts",
|
||||||
"thought_stats",
|
"thought_stats",
|
||||||
"update_recipe",
|
|
||||||
"update_thought",
|
"update_thought",
|
||||||
"upload_file",
|
"upload_file",
|
||||||
}
|
}
|
||||||
|
|||||||
136
internal/mcpserver/sse_test.go
Normal file
136
internal/mcpserver/sse_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package mcpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewHandlers_SSEDisabledByDefault(t *testing.T) {
|
||||||
|
h, err := NewHandlers(config.MCPConfig{
|
||||||
|
ServerName: "test",
|
||||||
|
Version: "0.0.1",
|
||||||
|
SessionTimeout: time.Minute,
|
||||||
|
}, nil, streamableTestToolSet(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHandlers() error = %v", err)
|
||||||
|
}
|
||||||
|
if h.StreamableHTTP == nil {
|
||||||
|
t.Fatal("StreamableHTTP handler is nil")
|
||||||
|
}
|
||||||
|
if h.SSE != nil {
|
||||||
|
t.Fatal("SSE handler should be nil when SSEPath is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHandlers_SSEEnabledWhenPathSet(t *testing.T) {
|
||||||
|
h, err := NewHandlers(config.MCPConfig{
|
||||||
|
ServerName: "test",
|
||||||
|
Version: "0.0.1",
|
||||||
|
SessionTimeout: time.Minute,
|
||||||
|
SSEPath: "/sse",
|
||||||
|
}, nil, streamableTestToolSet(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHandlers() error = %v", err)
|
||||||
|
}
|
||||||
|
if h.StreamableHTTP == nil {
|
||||||
|
t.Fatal("StreamableHTTP handler is nil")
|
||||||
|
}
|
||||||
|
if h.SSE == nil {
|
||||||
|
t.Fatal("SSE handler is nil when SSEPath is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_BackwardCompatibility(t *testing.T) {
|
||||||
|
handler, err := New(config.MCPConfig{
|
||||||
|
ServerName: "test",
|
||||||
|
Version: "0.0.1",
|
||||||
|
SessionTimeout: time.Minute,
|
||||||
|
}, nil, streamableTestToolSet(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
if handler == nil {
|
||||||
|
t.Fatal("New() returned nil handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSEListTools(t *testing.T) {
|
||||||
|
h, err := NewHandlers(config.MCPConfig{
|
||||||
|
ServerName: "test",
|
||||||
|
Version: "0.0.1",
|
||||||
|
SessionTimeout: time.Minute,
|
||||||
|
SSEPath: "/sse",
|
||||||
|
}, nil, streamableTestToolSet(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHandlers() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(h.SSE)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "0.0.1"}, nil)
|
||||||
|
cs, err := client.Connect(context.Background(), &mcp.SSEClientTransport{Endpoint: srv.URL}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect SSE client: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = cs.Close() })
|
||||||
|
|
||||||
|
result, err := cs.ListTools(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTools() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Tools) == 0 {
|
||||||
|
t.Fatal("ListTools() returned no tools")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSEAndStreamableShareTools(t *testing.T) {
|
||||||
|
h, err := NewHandlers(config.MCPConfig{
|
||||||
|
ServerName: "test",
|
||||||
|
Version: "0.0.1",
|
||||||
|
SessionTimeout: time.Minute,
|
||||||
|
SSEPath: "/sse",
|
||||||
|
}, nil, streamableTestToolSet(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHandlers() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sseSrv := httptest.NewServer(h.SSE)
|
||||||
|
t.Cleanup(sseSrv.Close)
|
||||||
|
|
||||||
|
streamSrv := httptest.NewServer(h.StreamableHTTP)
|
||||||
|
t.Cleanup(streamSrv.Close)
|
||||||
|
|
||||||
|
sseClient := mcp.NewClient(&mcp.Implementation{Name: "sse-client", Version: "0.0.1"}, nil)
|
||||||
|
sseSession, err := sseClient.Connect(context.Background(), &mcp.SSEClientTransport{Endpoint: sseSrv.URL}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect SSE client: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = sseSession.Close() })
|
||||||
|
|
||||||
|
streamClient := mcp.NewClient(&mcp.Implementation{Name: "stream-client", Version: "0.0.1"}, nil)
|
||||||
|
streamSession, err := streamClient.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: streamSrv.URL}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect StreamableHTTP client: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = streamSession.Close() })
|
||||||
|
|
||||||
|
sseTools, err := sseSession.ListTools(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SSE ListTools() error = %v", err)
|
||||||
|
}
|
||||||
|
streamTools, err := streamSession.ListTools(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamableHTTP ListTools() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sseTools.Tools) != len(streamTools.Tools) {
|
||||||
|
t.Fatalf("SSE tool count = %d, StreamableHTTP tool count = %d, want equal", len(sseTools.Tools), len(streamTools.Tools))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,86 @@ func TestStreamableHTTPReturnsStructuredToolErrors(t *testing.T) {
|
|||||||
t.Fatalf("build_date = %#v, want %q", got["build_date"], "2026-03-31T00:00:00Z")
|
t.Fatalf("build_date = %#v, want %q", got["build_date"], "2026-03-31T00:00:00Z")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("add_learning_requires_summary", func(t *testing.T) {
|
||||||
|
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||||
|
Name: "add_learning",
|
||||||
|
Arguments: map[string]any{},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CallTool(add_learning) error = nil, want error")
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcErr, data := requireWireError(t, err)
|
||||||
|
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||||
|
t.Fatalf("add_learning code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||||
|
}
|
||||||
|
if data.Type != mcperrors.TypeInvalidArguments {
|
||||||
|
t.Fatalf("add_learning data.type = %q, want %q", data.Type, mcperrors.TypeInvalidArguments)
|
||||||
|
}
|
||||||
|
if data.Field != "summary" {
|
||||||
|
t.Fatalf("add_learning data.field = %q, want %q", data.Field, "summary")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get_learning_requires_id", func(t *testing.T) {
|
||||||
|
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||||
|
Name: "get_learning",
|
||||||
|
Arguments: map[string]any{},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CallTool(get_learning) error = nil, want error")
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcErr, data := requireWireError(t, err)
|
||||||
|
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||||
|
t.Fatalf("get_learning code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||||
|
}
|
||||||
|
if data.Type != mcperrors.TypeInvalidArguments {
|
||||||
|
t.Fatalf("get_learning data.type = %q, want %q", data.Type, mcperrors.TypeInvalidArguments)
|
||||||
|
}
|
||||||
|
if data.Field != "id" {
|
||||||
|
t.Fatalf("get_learning data.field = %q, want %q", data.Field, "id")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("add_learning_unconfigured_returns_structured_error", func(t *testing.T) {
|
||||||
|
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||||
|
Name: "add_learning",
|
||||||
|
Arguments: map[string]any{
|
||||||
|
"summary": "Learning with configured check",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CallTool(add_learning) error = nil, want error")
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcErr, data := requireWireError(t, err)
|
||||||
|
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||||
|
t.Fatalf("add_learning code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||||
|
}
|
||||||
|
if data.Type != mcperrors.TypeInvalidInput {
|
||||||
|
t.Fatalf("add_learning data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("list_learnings_unconfigured_returns_structured_error", func(t *testing.T) {
|
||||||
|
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||||
|
Name: "list_learnings",
|
||||||
|
Arguments: map[string]any{},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CallTool(list_learnings) error = nil, want error")
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcErr, data := requireWireError(t, err)
|
||||||
|
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||||
|
t.Fatalf("list_learnings code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||||
|
}
|
||||||
|
if data.Type != mcperrors.TypeInvalidInput {
|
||||||
|
t.Fatalf("list_learnings data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func streamableTestToolSet() ToolSet {
|
func streamableTestToolSet() ToolSet {
|
||||||
@@ -126,12 +206,8 @@ func streamableTestToolSet() ToolSet {
|
|||||||
Files: new(tools.FilesTool),
|
Files: new(tools.FilesTool),
|
||||||
Backfill: new(tools.BackfillTool),
|
Backfill: new(tools.BackfillTool),
|
||||||
Reparse: new(tools.ReparseMetadataTool),
|
Reparse: new(tools.ReparseMetadataTool),
|
||||||
RetryMetadata: new(tools.RetryMetadataTool),
|
RetryMetadata: new(tools.RetryEnrichmentTool),
|
||||||
Household: new(tools.HouseholdTool),
|
|
||||||
Maintenance: new(tools.MaintenanceTool),
|
Maintenance: new(tools.MaintenanceTool),
|
||||||
Calendar: new(tools.CalendarTool),
|
|
||||||
Meals: new(tools.MealsTool),
|
|
||||||
CRM: new(tools.CRMTool),
|
|
||||||
Skills: new(tools.SkillsTool),
|
Skills: new(tools.SkillsTool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
package observability
|
package observability
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/requestip"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const requestIDContextKey contextKey = "request_id"
|
const requestIDContextKey contextKey = "request_id"
|
||||||
|
const mcpToolContextKey contextKey = "mcp_tool"
|
||||||
|
|
||||||
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
|
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
|
||||||
for i := len(middlewares) - 1; i >= 0; i-- {
|
for i := len(middlewares) - 1; i >= 0; i-- {
|
||||||
@@ -57,18 +63,27 @@ func Recover(log *slog.Logger) func(http.Handler) http.Handler {
|
|||||||
func AccessLog(log *slog.Logger) func(http.Handler) http.Handler {
|
func AccessLog(log *slog.Logger) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if tool := mcpToolFromRequest(r); tool != "" {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), mcpToolContextKey, tool))
|
||||||
|
}
|
||||||
|
|
||||||
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
next.ServeHTTP(recorder, r)
|
next.ServeHTTP(recorder, r)
|
||||||
|
|
||||||
log.Info("http request",
|
attrs := []any{
|
||||||
slog.String("request_id", RequestIDFromContext(r.Context())),
|
slog.String("request_id", RequestIDFromContext(r.Context())),
|
||||||
slog.String("method", r.Method),
|
slog.String("method", r.Method),
|
||||||
slog.String("path", r.URL.Path),
|
slog.String("path", r.URL.Path),
|
||||||
slog.Int("status", recorder.status),
|
slog.Int("status", recorder.status),
|
||||||
slog.Duration("duration", time.Since(started)),
|
slog.Duration("duration", time.Since(started)),
|
||||||
slog.String("remote_addr", stripPort(r.RemoteAddr)),
|
slog.String("remote_addr", requestip.FromRequest(r)),
|
||||||
)
|
slog.String("mcp_session_id", mcpSessionIDFromRequest(r)),
|
||||||
|
}
|
||||||
|
if tool, _ := r.Context().Value(mcpToolContextKey).(string); strings.TrimSpace(tool) != "" {
|
||||||
|
attrs = append(attrs, slog.String("tool", tool), slog.String("tool_call", tool))
|
||||||
|
}
|
||||||
|
log.Info("http request", attrs...)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +106,11 @@ func RequestIDFromContext(ctx context.Context) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MCPToolFromContext(ctx context.Context) string {
|
||||||
|
value, _ := ctx.Value(mcpToolContextKey).(string)
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
type statusRecorder struct {
|
type statusRecorder struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
status int
|
status int
|
||||||
@@ -101,10 +121,67 @@ func (s *statusRecorder) WriteHeader(statusCode int) {
|
|||||||
s.ResponseWriter.WriteHeader(statusCode)
|
s.ResponseWriter.WriteHeader(statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripPort(remote string) string {
|
func mcpToolFromRequest(r *http.Request) string {
|
||||||
host, _, err := net.SplitHostPort(remote)
|
if r == nil || r.Method != http.MethodPost || !strings.HasPrefix(r.URL.Path, "/mcp") || r.Body == nil {
|
||||||
if err != nil {
|
return ""
|
||||||
return remote
|
|
||||||
}
|
}
|
||||||
return host
|
|
||||||
|
raw, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(raw))
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support both single and batch JSON-RPC payloads.
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
|
||||||
|
var batch []rpcEnvelope
|
||||||
|
if err := json.Unmarshal(raw, &batch); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, msg := range batch {
|
||||||
|
if tool := msg.toolName(); tool != "" {
|
||||||
|
return tool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg rpcEnvelope
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return msg.toolName()
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpSessionIDFromRequest(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(r.Header.Get("MCP-Session-Id")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
// Some clients/proxies may propagate the session in query params.
|
||||||
|
for _, key := range []string{"session_id", "sessionId", "mcp_session_id"} {
|
||||||
|
if v := strings.TrimSpace(r.URL.Query().Get(key)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcEnvelope struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m rpcEnvelope) toolName() string {
|
||||||
|
if m.Method != "tools/call" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(m.Params.Name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package observability
|
package observability
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -57,3 +60,99 @@ func TestRecoverHandlesPanic(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAccessLogUsesForwardedClientIP(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
||||||
|
handler := AccessLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.10:1234"
|
||||||
|
req.Header.Set("X-Real-IP", "203.0.113.7")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "remote_addr=203.0.113.7") {
|
||||||
|
t.Fatalf("log output = %q, want remote_addr=203.0.113.7", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessLogIncludesMCPToolName(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
||||||
|
handler := AccessLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "1",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": map[string]any{
|
||||||
|
"name": "list_projects",
|
||||||
|
"arguments": map[string]any{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "tool=list_projects") {
|
||||||
|
t.Fatalf("log output = %q, want tool=list_projects", buf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "tool_call=list_projects") {
|
||||||
|
t.Fatalf("log output = %q, want tool_call=list_projects", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessLogIncludesMCPSessionIDHeader(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
||||||
|
handler := AccessLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/sse", nil)
|
||||||
|
req.Header.Set("MCP-Session-Id", "sess-123")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "mcp_session_id=sess-123") {
|
||||||
|
t.Fatalf("log output = %q, want mcp_session_id=sess-123", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessLogIncludesMCPSessionIDQueryParam(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
||||||
|
handler := AccessLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/sse?session_id=sess-q-1", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "mcp_session_id=sess-q-1") {
|
||||||
|
t.Fatalf("log output = %q, want mcp_session_id=sess-q-1", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
72
internal/requestip/requestip.go
Normal file
72
internal/requestip/requestip.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package requestip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FromRequest returns the best-effort client IP/host for a request, preferring
|
||||||
|
// proxy headers before falling back to RemoteAddr.
|
||||||
|
//
|
||||||
|
// Header precedence:
|
||||||
|
// 1) X-Real-IP
|
||||||
|
// 2) X-Forwarded-For (first value)
|
||||||
|
// 3) Forwarded (for=...)
|
||||||
|
// 4) RemoteAddr (host part)
|
||||||
|
func FromRequest(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := firstAddressToken(r.Header.Get("X-Real-IP")); v != "" {
|
||||||
|
return stripPort(v)
|
||||||
|
}
|
||||||
|
if v := firstAddressToken(r.Header.Get("X-Forwarded-For")); v != "" {
|
||||||
|
return stripPort(v)
|
||||||
|
}
|
||||||
|
if v := forwardedForValue(r.Header.Get("Forwarded")); v != "" {
|
||||||
|
return stripPort(v)
|
||||||
|
}
|
||||||
|
return stripPort(strings.TrimSpace(r.RemoteAddr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstAddressToken(v string) string {
|
||||||
|
if v == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
part := strings.TrimSpace(strings.Split(v, ",")[0])
|
||||||
|
part = strings.Trim(part, `"`)
|
||||||
|
return strings.TrimSpace(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
func forwardedForValue(v string) string {
|
||||||
|
for _, part := range strings.Split(v, ",") {
|
||||||
|
for _, kv := range strings.Split(part, ";") {
|
||||||
|
k, raw, ok := strings.Cut(strings.TrimSpace(kv), "=")
|
||||||
|
if !ok || !strings.EqualFold(strings.TrimSpace(k), "for") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate := strings.Trim(strings.TrimSpace(raw), `"`)
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripPort(addr string) string {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// RFC 7239 quoted values may wrap IPv6 with brackets.
|
||||||
|
addr = strings.Trim(addr, "[]")
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
37
internal/requestip/requestip_test.go
Normal file
37
internal/requestip/requestip_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package requestip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFromRequestPrefersXRealIP(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.10:5555"
|
||||||
|
req.Header.Set("X-Forwarded-For", "198.51.100.1")
|
||||||
|
req.Header.Set("X-Real-IP", "203.0.113.10")
|
||||||
|
|
||||||
|
if got := FromRequest(req); got != "203.0.113.10" {
|
||||||
|
t.Fatalf("FromRequest() = %q, want %q", got, "203.0.113.10")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromRequestUsesXForwardedForFirstValue(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.10:5555"
|
||||||
|
req.Header.Set("X-Forwarded-For", "198.51.100.7, 10.1.1.2")
|
||||||
|
|
||||||
|
if got := FromRequest(req); got != "198.51.100.7" {
|
||||||
|
t.Fatalf("FromRequest() = %q, want %q", got, "198.51.100.7")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromRequestFallsBackToRemoteAddr(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = "192.0.2.5:1234"
|
||||||
|
|
||||||
|
if got := FromRequest(req); got != "192.0.2.5" {
|
||||||
|
t.Fatalf("FromRequest() = %q, want %q", got, "192.0.2.5")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,9 +20,12 @@ func (db *DB) AddFamilyMember(ctx context.Context, m ext.FamilyMember) (ext.Fami
|
|||||||
`, m.Name, nullStr(m.Relationship), m.BirthDate, nullStr(m.Notes))
|
`, m.Name, nullStr(m.Relationship), m.BirthDate, nullStr(m.Notes))
|
||||||
|
|
||||||
created := m
|
created := m
|
||||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
var model generatedmodels.ModelPublicFamilyMembers
|
||||||
|
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||||
return ext.FamilyMember{}, fmt.Errorf("insert family member: %w", err)
|
return ext.FamilyMember{}, fmt.Errorf("insert family member: %w", err)
|
||||||
}
|
}
|
||||||
|
created.ID = model.ID.UUID()
|
||||||
|
created.CreatedAt = model.CreatedAt.Time()
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,14 +38,11 @@ func (db *DB) ListFamilyMembers(ctx context.Context) ([]ext.FamilyMember, error)
|
|||||||
|
|
||||||
var members []ext.FamilyMember
|
var members []ext.FamilyMember
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m ext.FamilyMember
|
var model generatedmodels.ModelPublicFamilyMembers
|
||||||
var relationship, notes *string
|
if err := rows.Scan(&model.ID, &model.Name, &model.Relationship, &model.BirthDate, &model.Notes, &model.CreatedAt); err != nil {
|
||||||
if err := rows.Scan(&m.ID, &m.Name, &relationship, &m.BirthDate, ¬es, &m.CreatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan family member: %w", err)
|
return nil, fmt.Errorf("scan family member: %w", err)
|
||||||
}
|
}
|
||||||
m.Relationship = strVal(relationship)
|
members = append(members, familyMemberFromModel(model))
|
||||||
m.Notes = strVal(notes)
|
|
||||||
members = append(members, m)
|
|
||||||
}
|
}
|
||||||
return members, rows.Err()
|
return members, rows.Err()
|
||||||
}
|
}
|
||||||
@@ -56,9 +57,12 @@ func (db *DB) AddActivity(ctx context.Context, a ext.Activity) (ext.Activity, er
|
|||||||
nullStr(a.Location), nullStr(a.Notes))
|
nullStr(a.Location), nullStr(a.Notes))
|
||||||
|
|
||||||
created := a
|
created := a
|
||||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
var model generatedmodels.ModelPublicActivities
|
||||||
|
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||||
return ext.Activity{}, fmt.Errorf("insert activity: %w", err)
|
return ext.Activity{}, fmt.Errorf("insert activity: %w", err)
|
||||||
}
|
}
|
||||||
|
created.ID = model.ID.UUID()
|
||||||
|
created.CreatedAt = model.CreatedAt.Time()
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,9 +133,12 @@ func (db *DB) AddImportantDate(ctx context.Context, d ext.ImportantDate) (ext.Im
|
|||||||
`, d.FamilyMemberID, d.Title, d.DateValue, d.RecurringYearly, d.ReminderDaysBefore, nullStr(d.Notes))
|
`, d.FamilyMemberID, d.Title, d.DateValue, d.RecurringYearly, d.ReminderDaysBefore, nullStr(d.Notes))
|
||||||
|
|
||||||
created := d
|
created := d
|
||||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
var model generatedmodels.ModelPublicImportantDates
|
||||||
|
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||||
return ext.ImportantDate{}, fmt.Errorf("insert important date: %w", err)
|
return ext.ImportantDate{}, fmt.Errorf("insert important date: %w", err)
|
||||||
}
|
}
|
||||||
|
created.ID = model.ID.UUID()
|
||||||
|
created.CreatedAt = model.CreatedAt.Time()
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,17 +171,13 @@ func (db *DB) GetUpcomingDates(ctx context.Context, daysAhead int) ([]ext.Import
|
|||||||
|
|
||||||
var dates []ext.ImportantDate
|
var dates []ext.ImportantDate
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d ext.ImportantDate
|
var model generatedmodels.ModelPublicImportantDates
|
||||||
var memberID *uuid.UUID
|
var memberName *string
|
||||||
var memberName, notes *string
|
if err := rows.Scan(&model.ID, &model.FamilyMemberID, &memberName, &model.Title, &model.DateValue,
|
||||||
if err := rows.Scan(&d.ID, &memberID, &memberName, &d.Title, &d.DateValue,
|
&model.RecurringYearly, &model.ReminderDaysBefore, &model.Notes, &model.CreatedAt); err != nil {
|
||||||
&d.RecurringYearly, &d.ReminderDaysBefore, ¬es, &d.CreatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan important date: %w", err)
|
return nil, fmt.Errorf("scan important date: %w", err)
|
||||||
}
|
}
|
||||||
d.FamilyMemberID = memberID
|
dates = append(dates, importantDateFromModel(model, strVal(memberName)))
|
||||||
d.MemberName = strVal(memberName)
|
|
||||||
d.Notes = strVal(notes)
|
|
||||||
dates = append(dates, d)
|
|
||||||
}
|
}
|
||||||
return dates, rows.Err()
|
return dates, rows.Err()
|
||||||
}
|
}
|
||||||
@@ -188,23 +191,16 @@ func scanActivities(rows interface {
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var activities []ext.Activity
|
var activities []ext.Activity
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a ext.Activity
|
var model generatedmodels.ModelPublicActivities
|
||||||
var memberName, activityType, dayOfWeek, startTime, endTime, location, notes *string
|
var memberName *string
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&a.ID, &a.FamilyMemberID, &memberName, &a.Title, &activityType,
|
&model.ID, &model.FamilyMemberID, &memberName, &model.Title, &model.ActivityType,
|
||||||
&dayOfWeek, &startTime, &endTime,
|
&model.DayOfWeek, &model.StartTime, &model.EndTime,
|
||||||
&a.StartDate, &a.EndDate, &location, ¬es, &a.CreatedAt,
|
&model.StartDate, &model.EndDate, &model.Location, &model.Notes, &model.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("scan activity: %w", err)
|
return nil, fmt.Errorf("scan activity: %w", err)
|
||||||
}
|
}
|
||||||
a.MemberName = strVal(memberName)
|
activities = append(activities, activityFromModel(model, strVal(memberName)))
|
||||||
a.ActivityType = strVal(activityType)
|
|
||||||
a.DayOfWeek = strVal(dayOfWeek)
|
|
||||||
a.StartTime = strVal(startTime)
|
|
||||||
a.EndTime = strVal(endTime)
|
|
||||||
a.Location = strVal(location)
|
|
||||||
a.Notes = strVal(notes)
|
|
||||||
activities = append(activities, a)
|
|
||||||
}
|
}
|
||||||
return activities, rows.Err()
|
return activities, rows.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,9 +35,13 @@ func (db *DB) SaveChatHistory(ctx context.Context, h ext.ChatHistory) (ext.ChatH
|
|||||||
)
|
)
|
||||||
|
|
||||||
created := h
|
created := h
|
||||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
var model generatedmodels.ModelPublicChatHistories
|
||||||
|
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
return ext.ChatHistory{}, fmt.Errorf("insert chat history: %w", err)
|
return ext.ChatHistory{}, fmt.Errorf("insert chat history: %w", err)
|
||||||
}
|
}
|
||||||
|
created.ID = model.ID.UUID()
|
||||||
|
created.CreatedAt = model.CreatedAt.Time()
|
||||||
|
created.UpdatedAt = model.UpdatedAt.Time()
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,26 +162,33 @@ type rowScanner interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanChatHistory(row rowScanner) (ext.ChatHistory, error) {
|
func scanChatHistory(row rowScanner) (ext.ChatHistory, error) {
|
||||||
var h ext.ChatHistory
|
var model generatedmodels.ModelPublicChatHistories
|
||||||
var title, channel, agentID, summary *string
|
|
||||||
var messagesJSON, metaJSON []byte
|
|
||||||
|
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&h.ID, &h.SessionID, &title, &channel, &agentID, &h.ProjectID,
|
&model.ID, &model.SessionID, &model.Title, &model.Channel, &model.AgentID, &model.ProjectID,
|
||||||
&messagesJSON, &summary, &metaJSON, &h.CreatedAt, &h.UpdatedAt,
|
&model.Messages, &model.Summary, &model.Metadata, &model.CreatedAt, &model.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return ext.ChatHistory{}, err
|
return ext.ChatHistory{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Title = strVal(title)
|
h := ext.ChatHistory{
|
||||||
h.Channel = strVal(channel)
|
ID: model.ID.UUID(),
|
||||||
h.AgentID = strVal(agentID)
|
SessionID: model.SessionID.String(),
|
||||||
h.Summary = strVal(summary)
|
Title: model.Title.String(),
|
||||||
|
Channel: model.Channel.String(),
|
||||||
|
AgentID: model.AgentID.String(),
|
||||||
|
Summary: model.Summary.String(),
|
||||||
|
CreatedAt: model.CreatedAt.Time(),
|
||||||
|
UpdatedAt: model.UpdatedAt.Time(),
|
||||||
|
}
|
||||||
|
if model.ProjectID.Valid {
|
||||||
|
id := model.ProjectID.UUID()
|
||||||
|
h.ProjectID = &id
|
||||||
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(messagesJSON, &h.Messages); err != nil {
|
if err := json.Unmarshal(model.Messages, &h.Messages); err != nil {
|
||||||
return ext.ChatHistory{}, fmt.Errorf("unmarshal messages: %w", err)
|
return ext.ChatHistory{}, fmt.Errorf("unmarshal messages: %w", err)
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(metaJSON, &h.Metadata); err != nil {
|
if err := json.Unmarshal(model.Metadata, &h.Metadata); err != nil {
|
||||||
return ext.ChatHistory{}, fmt.Errorf("unmarshal metadata: %w", err)
|
return ext.ChatHistory{}, fmt.Errorf("unmarshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
if h.Messages == nil {
|
if h.Messages == nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,9 +25,13 @@ func (db *DB) AddProfessionalContact(ctx context.Context, c ext.ProfessionalCont
|
|||||||
nullStr(c.LinkedInURL), nullStr(c.HowWeMet), c.Tags, nullStr(c.Notes), c.FollowUpDate)
|
nullStr(c.LinkedInURL), nullStr(c.HowWeMet), c.Tags, nullStr(c.Notes), c.FollowUpDate)
|
||||||
|
|
||||||
created := c
|
created := c
|
||||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
var model generatedmodels.ModelPublicProfessionalContacts
|
||||||
|
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
return ext.ProfessionalContact{}, fmt.Errorf("insert contact: %w", err)
|
return ext.ProfessionalContact{}, fmt.Errorf("insert contact: %w", err)
|
||||||
}
|
}
|
||||||
|
created.ID = model.ID.UUID()
|
||||||
|
created.CreatedAt = model.CreatedAt.Time()
|
||||||
|
created.UpdatedAt = model.UpdatedAt.Time()
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +50,7 @@ func (db *DB) SearchContacts(ctx context.Context, query string, tags []string) (
|
|||||||
conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
|
conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
|
||||||
}
|
}
|
||||||
|
|
||||||
q := `select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, last_contacted, follow_up_date, created_at, updated_at from professional_contacts`
|
q := `select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at from professional_contacts`
|
||||||
if len(conditions) > 0 {
|
if len(conditions) > 0 {
|
||||||
q += " where " + strings.Join(conditions, " and ")
|
q += " where " + strings.Join(conditions, " and ")
|
||||||
}
|
}
|
||||||
@@ -62,27 +67,18 @@ func (db *DB) SearchContacts(ctx context.Context, query string, tags []string) (
|
|||||||
|
|
||||||
func (db *DB) GetContact(ctx context.Context, id uuid.UUID) (ext.ProfessionalContact, error) {
|
func (db *DB) GetContact(ctx context.Context, id uuid.UUID) (ext.ProfessionalContact, error) {
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, last_contacted, follow_up_date, created_at, updated_at
|
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at
|
||||||
from professional_contacts where id = $1
|
from professional_contacts where id = $1
|
||||||
`, id)
|
`, id)
|
||||||
|
|
||||||
var c ext.ProfessionalContact
|
var model generatedmodels.ModelPublicProfessionalContacts
|
||||||
var company, title, email, phone, linkedInURL, howWeMet, notes *string
|
var tags []string
|
||||||
if err := row.Scan(&c.ID, &c.Name, &company, &title, &email, &phone,
|
if err := row.Scan(&model.ID, &model.Name, &model.Company, &model.Title, &model.Email, &model.Phone,
|
||||||
&linkedInURL, &howWeMet, &c.Tags, ¬es, &c.LastContacted, &c.FollowUpDate,
|
&model.LinkedinURL, &model.HowWeMet, &tags, &model.Notes, &model.LastContacted, &model.FollowUpDate,
|
||||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
&model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
return ext.ProfessionalContact{}, fmt.Errorf("get contact: %w", err)
|
return ext.ProfessionalContact{}, fmt.Errorf("get contact: %w", err)
|
||||||
}
|
}
|
||||||
c.Company = strVal(company)
|
c := professionalContactFromModel(model, tags)
|
||||||
c.Title = strVal(title)
|
|
||||||
c.Email = strVal(email)
|
|
||||||
c.Phone = strVal(phone)
|
|
||||||
c.LinkedInURL = strVal(linkedInURL)
|
|
||||||
c.HowWeMet = strVal(howWeMet)
|
|
||||||
c.Notes = strVal(notes)
|
|
||||||
if c.Tags == nil {
|
|
||||||
c.Tags = []string{}
|
|
||||||
}
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,9 +97,12 @@ func (db *DB) LogInteraction(ctx context.Context, interaction ext.ContactInterac
|
|||||||
|
|
||||||
created := interaction
|
created := interaction
|
||||||
created.OccurredAt = occurredAt
|
created.OccurredAt = occurredAt
|
||||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
var model generatedmodels.ModelPublicContactInteractions
|
||||||
|
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||||
return ext.ContactInteraction{}, fmt.Errorf("insert interaction: %w", err)
|
return ext.ContactInteraction{}, fmt.Errorf("insert interaction: %w", err)
|
||||||
}
|
}
|
||||||
|
created.ID = model.ID.UUID()
|
||||||
|
created.CreatedAt = model.CreatedAt.Time()
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,14 +123,12 @@ func (db *DB) GetContactHistory(ctx context.Context, contactID uuid.UUID) (ext.C
|
|||||||
|
|
||||||
var interactions []ext.ContactInteraction
|
var interactions []ext.ContactInteraction
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ext.ContactInteraction
|
var model generatedmodels.ModelPublicContactInteractions
|
||||||
var followUpNotes *string
|
if err := rows.Scan(&model.ID, &model.ContactID, &model.InteractionType, &model.OccurredAt, &model.Summary,
|
||||||
if err := rows.Scan(&i.ID, &i.ContactID, &i.InteractionType, &i.OccurredAt, &i.Summary,
|
&model.FollowUpNeeded, &model.FollowUpNotes, &model.CreatedAt); err != nil {
|
||||||
&i.FollowUpNeeded, &followUpNotes, &i.CreatedAt); err != nil {
|
|
||||||
return ext.ContactHistory{}, fmt.Errorf("scan interaction: %w", err)
|
return ext.ContactHistory{}, fmt.Errorf("scan interaction: %w", err)
|
||||||
}
|
}
|
||||||
i.FollowUpNotes = strVal(followUpNotes)
|
interactions = append(interactions, contactInteractionFromModel(model))
|
||||||
interactions = append(interactions, i)
|
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return ext.ContactHistory{}, err
|
return ext.ContactHistory{}, err
|
||||||
@@ -148,15 +145,12 @@ func (db *DB) GetContactHistory(ctx context.Context, contactID uuid.UUID) (ext.C
|
|||||||
|
|
||||||
var opportunities []ext.Opportunity
|
var opportunities []ext.Opportunity
|
||||||
for oppRows.Next() {
|
for oppRows.Next() {
|
||||||
var o ext.Opportunity
|
var model generatedmodels.ModelPublicOpportunities
|
||||||
var description, notes *string
|
if err := oppRows.Scan(&model.ID, &model.ContactID, &model.Title, &model.Description, &model.Stage, &model.Value,
|
||||||
if err := oppRows.Scan(&o.ID, &o.ContactID, &o.Title, &description, &o.Stage, &o.Value,
|
&model.ExpectedCloseDate, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
&o.ExpectedCloseDate, ¬es, &o.CreatedAt, &o.UpdatedAt); err != nil {
|
|
||||||
return ext.ContactHistory{}, fmt.Errorf("scan opportunity: %w", err)
|
return ext.ContactHistory{}, fmt.Errorf("scan opportunity: %w", err)
|
||||||
}
|
}
|
||||||
o.Description = strVal(description)
|
opportunities = append(opportunities, opportunityFromModel(model))
|
||||||
o.Notes = strVal(notes)
|
|
||||||
opportunities = append(opportunities, o)
|
|
||||||
}
|
}
|
||||||
if err := oppRows.Err(); err != nil {
|
if err := oppRows.Err(); err != nil {
|
||||||
return ext.ContactHistory{}, err
|
return ext.ContactHistory{}, err
|
||||||
@@ -177,9 +171,13 @@ func (db *DB) CreateOpportunity(ctx context.Context, o ext.Opportunity) (ext.Opp
|
|||||||
`, o.ContactID, o.Title, nullStr(o.Description), o.Stage, o.Value, o.ExpectedCloseDate, nullStr(o.Notes))
|
`, o.ContactID, o.Title, nullStr(o.Description), o.Stage, o.Value, o.ExpectedCloseDate, nullStr(o.Notes))
|
||||||
|
|
||||||
created := o
|
created := o
|
||||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
var model generatedmodels.ModelPublicOpportunities
|
||||||
|
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
return ext.Opportunity{}, fmt.Errorf("insert opportunity: %w", err)
|
return ext.Opportunity{}, fmt.Errorf("insert opportunity: %w", err)
|
||||||
}
|
}
|
||||||
|
created.ID = model.ID.UUID()
|
||||||
|
created.CreatedAt = model.CreatedAt.Time()
|
||||||
|
created.UpdatedAt = model.UpdatedAt.Time()
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +188,7 @@ func (db *DB) GetFollowUpsDue(ctx context.Context, daysAhead int) ([]ext.Profess
|
|||||||
cutoff := time.Now().AddDate(0, 0, daysAhead)
|
cutoff := time.Now().AddDate(0, 0, daysAhead)
|
||||||
|
|
||||||
rows, err := db.pool.Query(ctx, `
|
rows, err := db.pool.Query(ctx, `
|
||||||
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, last_contacted, follow_up_date, created_at, updated_at
|
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at
|
||||||
from professional_contacts
|
from professional_contacts
|
||||||
where follow_up_date <= $1
|
where follow_up_date <= $1
|
||||||
order by follow_up_date asc
|
order by follow_up_date asc
|
||||||
@@ -224,24 +222,14 @@ func scanContacts(rows interface {
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var contacts []ext.ProfessionalContact
|
var contacts []ext.ProfessionalContact
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c ext.ProfessionalContact
|
var model generatedmodels.ModelPublicProfessionalContacts
|
||||||
var company, title, email, phone, linkedInURL, howWeMet, notes *string
|
var tags []string
|
||||||
if err := rows.Scan(&c.ID, &c.Name, &company, &title, &email, &phone,
|
if err := rows.Scan(&model.ID, &model.Name, &model.Company, &model.Title, &model.Email, &model.Phone,
|
||||||
&linkedInURL, &howWeMet, &c.Tags, ¬es, &c.LastContacted, &c.FollowUpDate,
|
&model.LinkedinURL, &model.HowWeMet, &tags, &model.Notes, &model.LastContacted, &model.FollowUpDate,
|
||||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
&model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("scan contact: %w", err)
|
return nil, fmt.Errorf("scan contact: %w", err)
|
||||||
}
|
}
|
||||||
c.Company = strVal(company)
|
contacts = append(contacts, professionalContactFromModel(model, tags))
|
||||||
c.Title = strVal(title)
|
|
||||||
c.Email = strVal(email)
|
|
||||||
c.Phone = strVal(phone)
|
|
||||||
c.LinkedInURL = strVal(linkedInURL)
|
|
||||||
c.HowWeMet = strVal(howWeMet)
|
|
||||||
c.Notes = strVal(notes)
|
|
||||||
if c.Tags == nil {
|
|
||||||
c.Tags = []string{}
|
|
||||||
}
|
|
||||||
contacts = append(contacts, c)
|
|
||||||
}
|
}
|
||||||
return contacts, rows.Err()
|
return contacts, rows.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,23 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
pgxvec "github.com/pgvector/pgvector-go/pgx"
|
pgxvec "github.com/pgvector/pgvector-go/pgx"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
"github.com/uptrace/bun/dialect/pgdialect"
|
||||||
|
"github.com/uptrace/bun/driver/pgdriver"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
|
bun *bun.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ctx context.Context, cfg config.DatabaseConfig) (*DB, error) {
|
func New(ctx context.Context, cfg config.DatabaseConfig) (*DB, error) {
|
||||||
@@ -35,8 +40,20 @@ func New(ctx context.Context, cfg config.DatabaseConfig) (*DB, error) {
|
|||||||
return nil, fmt.Errorf("create database pool: %w", err)
|
return nil, fmt.Errorf("create database pool: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db := &DB{pool: pool}
|
bunSQLDB := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(cfg.URL)))
|
||||||
|
bunSQLDB.SetMaxOpenConns(int(cfg.MaxConns))
|
||||||
|
bunSQLDB.SetMaxIdleConns(int(cfg.MinConns))
|
||||||
|
bunSQLDB.SetConnMaxLifetime(cfg.MaxConnLifetime)
|
||||||
|
bunSQLDB.SetConnMaxIdleTime(cfg.MaxConnIdleTime)
|
||||||
|
|
||||||
|
db := &DB{
|
||||||
|
pool: pool,
|
||||||
|
bun: bun.NewDB(bunSQLDB, pgdialect.New()),
|
||||||
|
}
|
||||||
if err := db.Ping(ctx); err != nil {
|
if err := db.Ping(ctx); err != nil {
|
||||||
|
if db.bun != nil {
|
||||||
|
_ = db.bun.Close()
|
||||||
|
}
|
||||||
pool.Close()
|
pool.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -45,11 +62,16 @@ func New(ctx context.Context, cfg config.DatabaseConfig) (*DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) Close() {
|
func (db *DB) Close() {
|
||||||
if db == nil || db.pool == nil {
|
if db == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
db.pool.Close()
|
if db.bun != nil {
|
||||||
|
_ = db.bun.Close()
|
||||||
|
}
|
||||||
|
if db.pool != nil {
|
||||||
|
db.pool.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) Ping(ctx context.Context) error {
|
func (db *DB) Ping(ctx context.Context) error {
|
||||||
@@ -102,3 +124,10 @@ func (db *DB) VerifyRequirements(ctx context.Context) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) Bun() *bun.DB {
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db.bun
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,24 +20,24 @@ func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile
|
|||||||
returning guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, created_at, updated_at
|
returning guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, created_at, updated_at
|
||||||
`, file.ThoughtID, file.ProjectID, file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content)
|
`, file.ThoughtID, file.ProjectID, file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content)
|
||||||
|
|
||||||
var created thoughttypes.StoredFile
|
var model generatedmodels.ModelPublicStoredFiles
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&created.ID,
|
&model.GUID,
|
||||||
&created.ThoughtID,
|
&model.ThoughtID,
|
||||||
&created.ProjectID,
|
&model.ProjectID,
|
||||||
&created.Name,
|
&model.Name,
|
||||||
&created.MediaType,
|
&model.MediaType,
|
||||||
&created.Kind,
|
&model.Kind,
|
||||||
&created.Encoding,
|
&model.Encoding,
|
||||||
&created.SizeBytes,
|
&model.SizeBytes,
|
||||||
&created.SHA256,
|
&model.Sha256,
|
||||||
&created.CreatedAt,
|
&model.CreatedAt,
|
||||||
&created.UpdatedAt,
|
&model.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return thoughttypes.StoredFile{}, fmt.Errorf("insert stored file: %w", err)
|
return thoughttypes.StoredFile{}, fmt.Errorf("insert stored file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return created, nil
|
return storedFileFromModel(model), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.StoredFile, error) {
|
func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.StoredFile, error) {
|
||||||
@@ -46,20 +47,20 @@ func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.Sto
|
|||||||
where guid = $1
|
where guid = $1
|
||||||
`, id)
|
`, id)
|
||||||
|
|
||||||
var file thoughttypes.StoredFile
|
var model generatedmodels.ModelPublicStoredFiles
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&file.ID,
|
&model.GUID,
|
||||||
&file.ThoughtID,
|
&model.ThoughtID,
|
||||||
&file.ProjectID,
|
&model.ProjectID,
|
||||||
&file.Name,
|
&model.Name,
|
||||||
&file.MediaType,
|
&model.MediaType,
|
||||||
&file.Kind,
|
&model.Kind,
|
||||||
&file.Encoding,
|
&model.Encoding,
|
||||||
&file.SizeBytes,
|
&model.SizeBytes,
|
||||||
&file.SHA256,
|
&model.Sha256,
|
||||||
&file.Content,
|
&model.Content,
|
||||||
&file.CreatedAt,
|
&model.CreatedAt,
|
||||||
&file.UpdatedAt,
|
&model.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if err == pgx.ErrNoRows {
|
||||||
return thoughttypes.StoredFile{}, err
|
return thoughttypes.StoredFile{}, err
|
||||||
@@ -67,7 +68,7 @@ func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.Sto
|
|||||||
return thoughttypes.StoredFile{}, fmt.Errorf("get stored file: %w", err)
|
return thoughttypes.StoredFile{}, fmt.Errorf("get stored file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return file, nil
|
return storedFileFromModel(model), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) ListStoredFiles(ctx context.Context, filter thoughttypes.StoredFileFilter) ([]thoughttypes.StoredFile, error) {
|
func (db *DB) ListStoredFiles(ctx context.Context, filter thoughttypes.StoredFileFilter) ([]thoughttypes.StoredFile, error) {
|
||||||
@@ -106,23 +107,23 @@ func (db *DB) ListStoredFiles(ctx context.Context, filter thoughttypes.StoredFil
|
|||||||
|
|
||||||
files := make([]thoughttypes.StoredFile, 0, filter.Limit)
|
files := make([]thoughttypes.StoredFile, 0, filter.Limit)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var file thoughttypes.StoredFile
|
var model generatedmodels.ModelPublicStoredFiles
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&file.ID,
|
&model.GUID,
|
||||||
&file.ThoughtID,
|
&model.ThoughtID,
|
||||||
&file.ProjectID,
|
&model.ProjectID,
|
||||||
&file.Name,
|
&model.Name,
|
||||||
&file.MediaType,
|
&model.MediaType,
|
||||||
&file.Kind,
|
&model.Kind,
|
||||||
&file.Encoding,
|
&model.Encoding,
|
||||||
&file.SizeBytes,
|
&model.SizeBytes,
|
||||||
&file.SHA256,
|
&model.Sha256,
|
||||||
&file.CreatedAt,
|
&model.CreatedAt,
|
||||||
&file.UpdatedAt,
|
&model.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("scan stored file: %w", err)
|
return nil, fmt.Errorf("scan stored file: %w", err)
|
||||||
}
|
}
|
||||||
files = append(files, file)
|
files = append(files, storedFileFromModel(model))
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("iterate stored files: %w", err)
|
return nil, fmt.Errorf("iterate stored files: %w", err)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user