diff --git a/docs/GRAZE.md b/docs/GRAZE.md index f5ccaa0c2..e25128477 100644 --- a/docs/GRAZE.md +++ b/docs/GRAZE.md @@ -52,7 +52,7 @@ Agents call named combos like `gary-scout`, `gary-adversarial`. The combo defini ### 4. Header contract -Defined here; parsed in a subsequent workstream. GaryOS injects these headers on every Claude Code worker invocation: +Implemented in PR #3. GaryOS injects these headers on every Claude Code worker invocation: | Header | Values | Purpose | |---|---|---| @@ -63,6 +63,23 @@ Defined here; parsed in a subsequent workstream. GaryOS injects these headers on Headers are observability and policy inputs only. They never override combo-resolved model selection. +**Implementation:** `src/lib/gary/context.ts` — `parseGaryContext(request)` parses all four headers, normalises enum values to lowercase, treats unknown enum values as `null` (warn + absent), appends to an in-memory 20-entry audit ring buffer, and best-effort-logs to SQLite via `logAuditEvent`. + +**Coverage surface** — `parseGaryContext` is called at the entry point of every `/v1/*` POST handler that handles agent traffic: + +| Route | Handler | +|---|---| +| `/v1/chat/completions` | `src/sse/handlers/chat.ts` → `handleChat()` | +| `/v1/embeddings` | `src/app/api/v1/embeddings/route.ts` → `POST()` | +| `/v1/search` | `src/app/api/v1/search/route.ts` → `POST()` | +| `/v1/rerank` | `src/app/api/v1/rerank/route.ts` → `POST()` | + +Not covered (intentionally): `GET /v1/embeddings` (list), `GET /v1/search` (list), `GET /v1/models` (read-only), `/v1/registered-keys` (admin), `/v1/batches` (async submit), `ws://` (WebSocket handshake). + +**Health endpoint:** `GET /monitoring/health` returns the last 20 gary-context entries under `garyContextHistory`. + +**Architecture note:** No global Next.js middleware. Next.js middleware runs on Edge Runtime, which is incompatible with the in-process SQLite audit log and the Node.js in-memory ring buffer. Per-handler injection is the only viable pattern given this constraint. + ### 5. Provider metadata fields Added to the provider config schema. Schema invariant: if `local=true` OR `e2ee=true`, then `trains_on_data` must be `false` (neither architecture permits training on the data). diff --git a/src/app/api/v1/embeddings/route.ts b/src/app/api/v1/embeddings/route.ts index 9992f8273..cffbfb147 100644 --- a/src/app/api/v1/embeddings/route.ts +++ b/src/app/api/v1/embeddings/route.ts @@ -22,6 +22,7 @@ import { v1EmbeddingsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { getAllCustomModels, getProviderNodes } from "@/lib/localDb"; +import { parseGaryContext } from "@/lib/gary/context"; /** * Handle CORS preflight @@ -216,6 +217,7 @@ export async function handleValidatedEmbeddingRequestBody(body: ValidatedEmbeddi } export async function POST(request) { + parseGaryContext(request); let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts index e5ec4419b..e91e5a252 100644 --- a/src/app/api/v1/rerank/route.ts +++ b/src/app/api/v1/rerank/route.ts @@ -7,6 +7,7 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { v1RerankSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { getProviderNodes } from "@/lib/localDb"; +import { parseGaryContext } from "@/lib/gary/context"; /** * Handle CORS preflight @@ -45,6 +46,7 @@ function buildDynamicRerankProvider(node: any) { * and local provider_nodes (oMLX, vLLM, etc.) via dynamic routing. */ export async function POST(request) { + parseGaryContext(request); let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index 2f282b454..cfeeb6218 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -21,6 +21,7 @@ import { getOrCoalesce, SEARCH_CACHE_DEFAULT_TTL_MS, } from "@omniroute/open-sse/services/searchCache.ts"; +import { parseGaryContext } from "@/lib/gary/context"; const CORS_HEADERS = { "Access-Control-Allow-Methods": "GET, POST, OPTIONS", @@ -88,6 +89,7 @@ function buildDomainFilter(filters?: { * POST /v1/search — execute a web search */ export async function POST(request: Request) { + parseGaryContext(request); let rawBody: unknown; try { rawBody = await request.json(); diff --git a/tests/unit/gary-coverage-extension.test.ts b/tests/unit/gary-coverage-extension.test.ts new file mode 100644 index 000000000..58aa91e1c --- /dev/null +++ b/tests/unit/gary-coverage-extension.test.ts @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "graze-gary-coverage-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +// Dynamic imports so DATA_DIR is set before any module initialises SQLite +const { POST: embeddingsPost } = await import("../../src/app/api/v1/embeddings/route.ts"); +const { POST: searchPost } = await import("../../src/app/api/v1/search/route.ts"); +const { POST: rerankPost } = await import("../../src/app/api/v1/rerank/route.ts"); +const { getRecentGaryContexts } = await import("../../src/lib/gary/context.ts"); + +test.after(() => { + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function makeRequest(url: string, garyHeaders: Record) { + return new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...garyHeaders }, + body: "not-valid-json", + }); +} + +test("embeddings POST captures gary context in ring buffer", async () => { + const req = makeRequest("http://localhost/v1/embeddings", { + "x-gary-action-id": "embed-action-001", + "x-gary-stage": "draft", + "x-gary-playbook": "code-pr", + "x-gary-sensitivity": "tier-2", + }); + await embeddingsPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, "embed-action-001"); + assert.equal(entry.stage, "draft"); + assert.equal(entry.playbook, "code-pr"); + assert.equal(entry.sensitivity, "tier-2"); +}); + +test("embeddings POST captures null gary fields when no headers sent", async () => { + const req = makeRequest("http://localhost/v1/embeddings", {}); + await embeddingsPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, null); + assert.equal(entry.stage, null); + assert.equal(entry.playbook, null); + assert.equal(entry.sensitivity, null); +}); + +test("search POST captures gary context in ring buffer", async () => { + const req = makeRequest("http://localhost/v1/search", { + "x-gary-action-id": "search-action-002", + "x-gary-stage": "scout", + "x-gary-playbook": "research", + "x-gary-sensitivity": "tier-1", + }); + await searchPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, "search-action-002"); + assert.equal(entry.stage, "scout"); + assert.equal(entry.playbook, "research"); + assert.equal(entry.sensitivity, "tier-1"); +}); + +test("search POST captures null gary fields when no headers sent", async () => { + const req = makeRequest("http://localhost/v1/search", {}); + await searchPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, null); + assert.equal(entry.stage, null); + assert.equal(entry.playbook, null); + assert.equal(entry.sensitivity, null); +}); + +test("rerank POST captures gary context in ring buffer", async () => { + const req = makeRequest("http://localhost/v1/rerank", { + "x-gary-action-id": "rerank-action-003", + "x-gary-stage": "validate", + "x-gary-playbook": "decision", + "x-gary-sensitivity": "tier-3", + }); + await rerankPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, "rerank-action-003"); + assert.equal(entry.stage, "validate"); + assert.equal(entry.playbook, "decision"); + assert.equal(entry.sensitivity, "tier-3"); +}); + +test("rerank POST captures null gary fields when no headers sent", async () => { + const req = makeRequest("http://localhost/v1/rerank", {}); + await rerankPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, null); + assert.equal(entry.stage, null); + assert.equal(entry.playbook, null); + assert.equal(entry.sensitivity, null); +}); + +test("gary context ring buffer entries from non-chat handlers have same shape as chat entries", async () => { + const req = makeRequest("http://localhost/v1/embeddings", { + "x-gary-action-id": "shape-check-004", + "x-gary-stage": "triage", + }); + await embeddingsPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.ok("actionId" in entry, "missing actionId"); + assert.ok("stage" in entry, "missing stage"); + assert.ok("playbook" in entry, "missing playbook"); + assert.ok("sensitivity" in entry, "missing sensitivity"); + assert.ok("timestamp" in entry, "missing timestamp"); + assert.ok("requestId" in entry, "missing requestId"); + assert.equal(typeof entry.timestamp, "string"); + assert.ok(!Number.isNaN(Date.parse(entry.timestamp)), "timestamp is not ISO 8601"); +}); + +test("all three handlers register entries that accumulate in the shared ring buffer", async () => { + const before = getRecentGaryContexts().length; + + await embeddingsPost(makeRequest("http://localhost/v1/embeddings", { "x-gary-stage": "plan" })); + await searchPost(makeRequest("http://localhost/v1/search", { "x-gary-stage": "review-plan" })); + await rerankPost(makeRequest("http://localhost/v1/rerank", { "x-gary-stage": "submit" })); + + const entries = getRecentGaryContexts(); + const added = Math.min(entries.length, entries.length - before + 3); + assert.ok(entries.length >= Math.min(before + 3, 20), "expected at least 3 new entries (or ring at cap)"); + + // Most-recent three entries are in newest-first order + assert.equal(entries[0].stage, "submit"); + assert.equal(entries[1].stage, "review-plan"); + assert.equal(entries[2].stage, "plan"); +});