diff --git a/SPRINT_NOTES.md b/SPRINT_NOTES.md index 6c164253..87861227 100644 --- a/SPRINT_NOTES.md +++ b/SPRINT_NOTES.md @@ -117,3 +117,58 @@ Only 2 test files exist: `src/__tests__/security.test.ts`, `wallet-eligibility.t - **PublicAgent** now includes `slug` and `treasuryHealth`; still excludes all CRM tags. `/api/registry/agents` no longer leaks `outreachStatus`/`adminNotes`/`priority`. - **Agent type** gained required `slug` + the eight tag fields; `rowToAgent`/`agentToRow`/static `data.ts` handle them. `approvePendingUpdate` deliberately does NOT accept tag fields. - Tests: `src/__tests__/status-tags.test.ts` (bun harness still absent locally/CI — see §9.6). + +--- + +## 11. Sprint completion (2026-07-27) — P0–P9 all code-complete + +All phases merged to `main` (`7dda29b`). DoD green: typecheck 0 · lint 0 · **181 tests** · build clean (both flag states). CI runs the full gate on every PR. + +| Phase | Delivered | Key files | +|---|---|---| +| P0 | Scope lock + tag system + 2 migrations | `focus.ts`, `20260715*` migrations | +| P1 | Data-quality audit route/script/skill | `bankr-audit.ts`, `scripts/bankr-data-audit.sh` | +| P2 | Registry financial terminal | `registry-client.tsx`, `filters.ts` | +| P3+P4 | Single-scroll profile + operator books | `books-presenter.ts`, `books-sections.tsx` | +| P5 | Pipeline observability, fail-loud | `pipeline-observability.ts`, `20260717*` migration | +| P6 | Bankr Queue (6 admin views) | luca-admin `BankrQueueSection` | +| P7 | Claim/outreach CTAs + pending states | profile `ProfileCtas`, `ClaimBanner` | +| P8 | Agent-scoped keys + isolation tests + docs | `v1-auth.ts`, `agent-scope.test.ts`, `docs/API_BANKR.md` | +| P9 | Messaging rules enforced in CI | `messaging-rules.test.ts` | + +## 12. QA checklist run (3 Bankr agents: bankr, helixa, botcoin) + +Spec named **Luca** as a test agent — Luca is ecosystem `Base` and intentionally 404s under the P0 scope lock (verified). Substituted a third Bankr agent. + +| Check | Result | How verified | +|---|---|---| +| P3 layout order, bio before scores | ✅ | HTML marker positions: bio→status→metrics→verdict→books→attribution→data-quality. One documented deviation: for `no_books` agents the claim CTA renders directly after Books ("the CTA IS the next action") instead of position 11 — deliberate merged design. | +| Metrics real or explicitly missing, never fake zeros | ✅ | 0 occurrences of `$0.00` on all 3 profiles; unattributed metrics render "—" with reason | +| Luca verdict right after metrics, reflects known/missing | ✅ | verdict section immediately follows metrics; missing list populated (e.g. "Wallet attribution (no books-eligible manifest wallet)") | +| Wallet attribution shows source provenance | ✅ | source pills + legend (manifest/admin/inferred/candidate) render; bankr shows `candidate` | +| Token activity ≠ operating revenue | ✅ (by test) | presenter invariant tests; agent-token is its own row — no attributed agent exists in local fallback data to eyeball; re-verify once on prod | +| Books status / freshness / last-indexed / next-action populate | ✅ | Data Quality block fully rendered on all 3, concrete next action each | +| Registry row matches profile | ✅ | API `needs_verification/no_books/partial` == profile badges "Needs Verification/No Books/Partial" (bankr; spot-checked others) | +| Pipeline failure visible, no silent empty state | ✅ (by test) | data_status=failed overlay + admin section unit-tested; needs prod `pipeline_failures` table for a live example | +| Copy follows P9 rules | ✅ | `messaging-rules.test.ts` scans every public tsx in CI | +| Scoped key returns own data, denied for others | ✅ (by test) | `agent-scope.test.ts` security-property tests; live-key check needs a prod-issued `agent:{slug}` key | + +**Caveat:** local data is the static fallback (no Supabase env), so "✅ (by test)" rows are enforced by the unit suite rather than observed live. One prod pass over a live-books agent completes these visually. + +## 13. P0/P1 backlog — Bankr reliability (CTO deliverable #7) + +**P0 (blocking full value of shipped work):** +1. Apply `supabase/migrations/20260717000001_pipeline_failures.sql` in prod (P5 has no table until then). +2. Run the first production data audit (`scripts/bankr-data-audit.sh` with prod secret); commit `reports/` artifact — this is the real P1 deliverable's numbers. +3. Add Hermes schedules: weekly `bankr-data-audit` (Mon 06:00 UTC). +4. Issue `agent:{slug}` keys to pilot Bankr teams; verify one live 403 cross-agent denial. +5. Fill bios for top Bankr agents (admin Edit Agent Profile) — every profile currently shows the empty state. + +**P1 (reliability debt, next sprint):** +6. Tracked migrations for hand-created prod tables (`registry_manifest_submissions`, `luca_subagent_runs`, `registry_claims`) — schema currently not reproducible. +7. `outreach_status_legacy` cleanup migration once the new vocabulary is confirmed in practice. +8. Wallet-level v1 endpoints (`ledger-summary`, `full-report`, `scan`) are unscoped by design (public chain data) — consider verifying wallet-belongs-to-agent for scoped keys. +9. Gas tracking (no ledger category exists — expense breakdown honestly shows "not tracked"). +10. `valueUsd` pricing for provider-normalized transactions (currently honest-blank). +11. Sync `docs/API_BANKR.md` into the GitBook (`DOCS_URL`). +12. Visual QA pass on a live-books agent post-deploy (attributed rendering was test-verified only). diff --git a/docs/API_BANKR.md b/docs/API_BANKR.md new file mode 100644 index 00000000..f5cd415b --- /dev/null +++ b/docs/API_BANKR.md @@ -0,0 +1,119 @@ +# Zetta API for Bankr Agent Teams + +How a Bankr agent team reads its own financial data from Zetta, what each +number means (actual vs. estimated vs. missing), and what an agent-scoped key +can and cannot do. + +> Canonical hosted docs live at the GitBook (see `DOCS_URL`); this file is the +> source-of-truth spec for the Bankr-facing v1 endpoints and should be synced +> there. + +--- + +## Authentication + +Pass your key either way: + +``` +Authorization: Bearer +X-API-Key: +``` + +### Agent-scoped keys — `agent:{slug}` + +Keys named `agent:` (e.g. `agent:bankr-bot`) are **scoped**: +they can read that agent's data and nothing else. Scope is enforced centrally +in the API layer and covered by tests (`src/__tests__/agent-scope.test.ts`) — +a scoped key requesting another agent's data always receives: + +``` +403 { "error": "This API key is scoped to agent 'bankr-bot', not 'other-agent'. Use a key scoped to this agent, or an unscoped key." } +``` + +Scoped-key rules: +- **Read**: `agent-books`, `agent-books/history`, `agent-revenue`, + `agent-truth`, `agent-report`, `agent-events` — own slug only. +- **Write**: `POST /api/v1/agent-events` — a scoped key can only log events + under its own agent, never another's. +- Wallet-level endpoints (`ledger-summary`, `full-report`, `scan`, + `transactions`) query public on-chain data by wallet address and are not + agent-scoped — they never return Zetta's derived agent books. + +Request a scoped key at `/developer` or via the Zetta team. + +--- + +## Endpoints + +### `GET /api/v1/agent-books/{slug}?range=30d` +The core product output: revenue, expenses, net income, and treasury across +the agent's **declared** wallets, internal transfers eliminated. +`range`: `7d | 14d | 30d | 90d` (default `30d`). + +### `GET /api/v1/agent-books/{slug}/history` +Snapshots of past books periods (trend data). + +### `GET /api/v1/agent-revenue/{slug}?chain=&classification=&since=` +Classified revenue events (settlement detection, per-transaction). + +### `GET /api/v1/agent-truth/{slug}` +Observed-truth summary: classification counts, revenue candidates, +unresolved inflows — the raw evidence layer underneath books. + +### `GET /api/v1/agent-report?agentName={name}&days=7` +Plain-language financial report generated from the event log. + +### Not available (yet) +`/api/v1/agent-wallets/{slug}` and `/api/v1/agent-holdings/{slug}` **do not +exist**. Declared wallets and their roles appear inside the `agent-books` +response (`wallets`) and on the public profile; stablecoin treasury balance +appears as `financials.treasury_balance_usd`. + +--- + +## Reading the numbers: actual · estimated · missing · unavailable + +Zetta never fabricates a number. Every field falls into one of four classes: + +| Class | What it means | How it appears | +|---|---|---| +| **Actual** | Computed from on-chain transactions of manifest-declared, books-eligible wallets. | `financials.revenue_usd`, `expenses_usd`, `net_income_usd`, `tx_count`, `classification.settlement_revenue_usd` (highest-confidence tier: stablecoin inflows from known counterparties). | +| **Estimated / lower-confidence** | Real observations whose classification is not certain. Check `confidence.*` (`high/medium/low`) per metric. | `classification.agent_token_revenue_usd` (agent-token inflows — **never counted as operating revenue**), `fee_received`-tier events in `agent-truth`, anything the response flags in `confidence.flags`. | +| **Missing** | Not tracked or not fetchable right now — reported as `null`, **never as `$0`**. | `treasury_balance_usd: null` (no treasury wallet, or the balance lookup failed), `runway_months: null` (needs treasury), gas costs (not tracked at all). | +| **Unavailable** | The agent has no attributed books. You get an honest `200` with `attributed: false` and a `reason` — not an empty ledger. | `{ "attributed": false, "reason": "no_manifest_wallets" \| "wallets_declared_not_scannable" \| "financials_under_review" }` | + +Also relevant: +- `classification.quarantined_inflows_usd` — inflows deliberately **held out** + of revenue (suspected capital injections, grants, bridge receipts) pending + classification. Quarantined ≠ revenue. +- `generated_at` — books freshness; the cache refreshes on a ~4h cycle. +- `agent-truth` classifications marked `unknown` stay unknown — ambiguous + activity is never auto-upgraded to revenue. + +--- + +## Errors + +| Status | Meaning | Example | +|---|---|---| +| 400 | Bad parameter — message names the field and valid values | `"Range must be 7d, 14d, 30d, or 90d."` | +| 401 | No/invalid key | `"Missing API key. Pass it as Authorization: Bearer or X-API-Key header."` | +| 403 | Scoped key requesting another agent | `"This API key is scoped to agent 'bankr-bot', not 'luca'. …"` | +| 404 | Unknown agent slug | `"Agent 'foo' not found in the registry. Browse agents at /registry."` | +| 503 | Registry/backing store unavailable | `"Registry unavailable"` | + +Rate limits apply per key; usage is recorded per endpoint. + +--- + +## Quick start (Bankr team, own data) + +```bash +KEY="" +curl -s -H "Authorization: Bearer $KEY" \ + https://www.zettaai.co/api/v1/agent-books/bankr-bot?range=30d | jq '.financials' + +# Proof of isolation — any other slug returns 403: +curl -s -H "Authorization: Bearer $KEY" \ + https://www.zettaai.co/api/v1/agent-books/luca | jq '.error' +``` diff --git a/src/__tests__/agent-scope.test.ts b/src/__tests__/agent-scope.test.ts index 80ad1f82..04d403b0 100644 --- a/src/__tests__/agent-scope.test.ts +++ b/src/__tests__/agent-scope.test.ts @@ -31,3 +31,41 @@ describe("agentScopeViolation", () => { expect(agentScopeViolation("aeon", undefined)).toBeNull(); }); }); + +// ── P8: agent:{slug} key parsing + Bankr end-to-end naming ──────────────────── + +import { parseAgentScope } from "@/lib/api-keys"; +import { toSlug } from "@/lib/slug"; + +describe("parseAgentScope (agent:{slug} key convention)", () => { + test("agent:bankr-bot → scope bankr-bot", () => { + expect(parseAgentScope("agent:bankr-bot")).toBe("bankr-bot"); + }); + + test("non-agent key names have no scope (unrestricted)", () => { + expect(parseAgentScope("my personal key")).toBeNull(); + expect(parseAgentScope("")).toBeNull(); + expect(parseAgentScope("agentx:bankr")).toBeNull(); + }); + + test("the security property: a Bankr agent's scoped key is denied every other slug", () => { + const scope = parseAgentScope("agent:bankr-bot"); + // own data — allowed + expect(agentScopeViolation(scope, "bankr-bot")).toBeNull(); + // anyone else's data — denied, with a clear error naming both slugs + for (const other of ["helixa", "clawd", "luca", "bankr"]) { + const v = agentScopeViolation(scope, other); + expect(v).not.toBeNull(); + expect(v).toContain("bankr-bot"); + expect(v).toContain(other); + } + }); + + test("agentName inputs normalize through toSlug before the scope check (agent-report / agent-events routes)", () => { + const scope = parseAgentScope("agent:bankr-bot"); + // A request for agentName "Bankr Bot" is the same agent → allowed + expect(agentScopeViolation(scope, toSlug("Bankr Bot"))).toBeNull(); + // A request for agentName "Sleuth.AI" is not → denied + expect(agentScopeViolation(scope, toSlug("Sleuth.AI"))).not.toBeNull(); + }); +}); diff --git a/src/__tests__/bankr-audit.test.ts b/src/__tests__/bankr-audit.test.ts index 5cf2f51f..b825c35a 100644 --- a/src/__tests__/bankr-audit.test.ts +++ b/src/__tests__/bankr-audit.test.ts @@ -6,6 +6,7 @@ import { describe, expect, test } from "bun:test"; import { + buildActionBuckets, buildAuditRow, buildCounts, buildDuplicateGroups, @@ -183,3 +184,79 @@ describe("buildAuditRow", () => { expect(row.duplicate_reasons).toEqual(["token_address", "x_handle"]); }); }); + +// ── P6 queue buckets ────────────────────────────────────────────────────────── + +describe("buildActionBuckets P6 views", () => { + const completeMeta = { + xHandle: "@dm-me", + website: "https://x.yz", + symbol: "$OK", + tokenAddress: "0x2222222222222222222222222222222222222222", + }; + + test("ready_for_outreach: complete metadata + no manifest + never contacted", () => { + const target = buildAuditRow(makeAgent({ ...completeMeta, slug: "dm-target", outreachStatus: null }), {}, {}); + const contacted = buildAuditRow( + makeAgent({ ...completeMeta, slug: "already-dmed", outreachStatus: "dm_sent" }), + {}, {}, + ); + const incompleteMeta = buildAuditRow( + makeAgent({ slug: "no-website", website: null, outreachStatus: null }), + {}, {}, + ); + const hasManifest = buildAuditRow( + makeAgent({ + ...completeMeta, + slug: "has-manifest", + outreachStatus: null, + wallets: [makeWallet({ evidenceSource: "manifest" })], + }), + {}, {}, + ); + const buckets = buildActionBuckets([target, contacted, incompleteMeta, hasManifest]); + expect(buckets.ready_for_outreach).toEqual(["dm-target"]); + }); + + test("ready_for_outreach also includes explicit not_contacted", () => { + const row = buildAuditRow( + makeAgent({ ...completeMeta, slug: "explicit", outreachStatus: "not_contacted" }), + {}, {}, + ); + expect(buildActionBuckets([row]).ready_for_outreach).toEqual(["explicit"]); + }); + + test("ready_to_verify: manifest wallets + needs_verification profile", () => { + const ready = buildAuditRow( + makeAgent({ + slug: "verify-me", + profileStatus: "needs_verification", + wallets: [makeWallet({ evidenceSource: "manifest" })], + }), + {}, {}, + ); + const alreadyVerified = buildAuditRow( + makeAgent({ + slug: "done", + profileStatus: "verified", + wallets: [makeWallet({ evidenceSource: "manifest" })], + }), + {}, {}, + ); + const noManifest = buildAuditRow( + makeAgent({ slug: "no-manifest", profileStatus: "needs_verification" }), + {}, {}, + ); + const buckets = buildActionBuckets([ready, alreadyVerified, noManifest]); + expect(buckets.ready_to_verify).toEqual(["verify-me"]); + }); + + test("outreach/priority CRM fields flow onto the audit row", () => { + const row = buildAuditRow( + makeAgent({ outreachStatus: "replied", bankrPriority: "high" }), + {}, {}, + ); + expect(row.outreach_status).toBe("replied"); + expect(row.bankr_priority).toBe("high"); + }); +}); diff --git a/src/__tests__/messaging-rules.test.ts b/src/__tests__/messaging-rules.test.ts new file mode 100644 index 00000000..400bdc01 --- /dev/null +++ b/src/__tests__/messaging-rules.test.ts @@ -0,0 +1,104 @@ +// P9 — UI messaging rules, enforced. +// Public-facing copy must never overclaim. Hard-banned phrases fail this +// suite outright; "official" is allowed ONLY on lines that tie it to the +// evidence that makes it true (a declared/submitted wallet manifest) — the +// product's own "no manifest, no official books" rule. +// +// Scope: public surfaces only (pages + components). Admin tooling +// (luca-admin) and API route internals are exempt. +// +// Run with: bun test src/__tests__/messaging-rules.test.ts + +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "fs"; +import { join } from "path"; + +const ROOT = join(import.meta.dir, ".."); + +// Phrases that overclaim no matter the context. +const HARD_BANNED = [/\baudited\b/i, /\breal revenue\b/i, /\bguaranteed\b/i, /\bconfirmed treasury\b/i]; + +// "official" must co-occur with the evidence condition on the same line. +const CONDITIONED = /\bofficial\b/i; +const EVIDENCE = /manifest|declar|submit/i; + +const EXCLUDED_DIRS = new Set(["luca-admin", "api", "__tests__", "node_modules"]); + +function publicTsxFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (EXCLUDED_DIRS.has(entry)) continue; + publicTsxFiles(full, out); + } else if (entry.endsWith(".tsx")) { + out.push(full); + } + } + return out; +} + +const FILES = [ + ...publicTsxFiles(join(ROOT, "app")), + ...publicTsxFiles(join(ROOT, "components")), +]; + +describe("P9 messaging rules on public surfaces", () => { + test("scans a real set of public files (sanity)", () => { + expect(FILES.length).toBeGreaterThan(10); + }); + + test("no hard-banned overclaims (audited / real revenue / guaranteed / confirmed treasury)", () => { + const violations: string[] = []; + for (const file of FILES) { + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((line, i) => { + for (const banned of HARD_BANNED) { + if (banned.test(line)) violations.push(`${file.replace(ROOT, "src")}:${i + 1} → ${line.trim().slice(0, 100)}`); + } + }); + } + expect(violations).toEqual([]); + }); + + test("'official' only appears evidence-conditioned (same line mentions manifest/declared/submit)", () => { + const violations: string[] = []; + for (const file of FILES) { + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((line, i) => { + if (CONDITIONED.test(line) && !EVIDENCE.test(line)) { + violations.push(`${file.replace(ROOT, "src")}:${i + 1} → ${line.trim().slice(0, 100)}`); + } + }); + } + expect(violations).toEqual([]); + }); +}); + +// The approved status vocabulary stays intact — these labels are what users +// see, and every one carries a plain-language tooltip (registry-filters tests +// assert the tooltips are non-empty). +import { BOOKS_STATUS_META, PROFILE_STATUS_META, WALLET_STATUS_META } from "../app/registry/filters"; + +describe("approved status vocabulary", () => { + test("core approved labels are present in the status metas", () => { + expect(PROFILE_STATUS_META.candidate.label).toBe("Candidate"); + expect(PROFILE_STATUS_META.needs_verification.label).toBe("Needs Verification"); + expect(PROFILE_STATUS_META.verified.label).toBe("Verified"); + expect(BOOKS_STATUS_META.no_books.label).toBe("No Books"); + expect(BOOKS_STATUS_META.live.label).toBe("Live"); + expect(BOOKS_STATUS_META.stale.label).toBe("Stale"); + expect(WALLET_STATUS_META.declared.label).toBe("Declared"); + }); + + test("no status label itself overclaims", () => { + const allLabels = [ + ...Object.values(PROFILE_STATUS_META), + ...Object.values(BOOKS_STATUS_META), + ...Object.values(WALLET_STATUS_META), + ].map((m) => m.label); + for (const label of allLabels) { + for (const banned of HARD_BANNED) expect(banned.test(label)).toBe(false); + expect(/official/i.test(label)).toBe(false); + } + }); +}); diff --git a/src/__tests__/pipeline-observability.test.ts b/src/__tests__/pipeline-observability.test.ts new file mode 100644 index 00000000..ca30bbbb --- /dev/null +++ b/src/__tests__/pipeline-observability.test.ts @@ -0,0 +1,85 @@ +// P5 pipeline observability tests — pure surface only. +// The stage vocabulary must match the DB CHECK; data_status must reflect a +// live failure; and Etherscan empty must be distinguished from an error. +// +// Run with: bun test src/__tests__/pipeline-observability.test.ts + +import { describe, expect, test } from "bun:test"; +import { PIPELINE_STAGES, isPipelineStage } from "../lib/pipeline-observability"; +import { parseEtherscanStatus } from "../lib/truth-engine/providers/etherscan"; +import { deriveDataStatus } from "../lib/status-tags"; + +const NOW = Date.parse("2026-07-17T12:00:00Z"); +const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString(); +const DAY = 24 * 60 * 60 * 1000; + +// ── Stage vocabulary ────────────────────────────────────────────────────────── + +describe("PIPELINE_STAGES", () => { + test("matches the 10 stages in the migration CHECK constraint, in order", () => { + expect([...PIPELINE_STAGES]).toEqual([ + "agent_indexed", + "metadata_complete", + "wallet_declared", + "wallet_eligible", + "transactions_fetched", + "transactions_normalized", + "transactions_classified", + "books_generated", + "profile_updated", + "luca_report_generated", + ]); + }); + + test("isPipelineStage guards membership", () => { + expect(isPipelineStage("books_generated")).toBe(true); + expect(isPipelineStage("bogus_stage")).toBe(false); + expect(isPipelineStage("")).toBe(false); + }); +}); + +// ── data_status failure overlay ─────────────────────────────────────────────── + +describe("deriveDataStatus with active failure", () => { + test("an active failure overrides fresh", () => { + expect(deriveDataStatus(iso(0), NOW, true)).toBe("failed"); + }); + + test("an active failure overrides stale and partial", () => { + expect(deriveDataStatus(iso(30 * DAY), NOW, true)).toBe("failed"); + expect(deriveDataStatus(null, NOW, true)).toBe("failed"); + }); + + test("no failure → normal freshness logic (fresh / stale / partial)", () => { + expect(deriveDataStatus(iso(2 * DAY), NOW, false)).toBe("fresh"); + expect(deriveDataStatus(iso(10 * DAY), NOW, false)).toBe("stale"); + expect(deriveDataStatus(null, NOW, false)).toBe("partial"); + }); + + test("defaults to no-failure when the flag is omitted", () => { + expect(deriveDataStatus(iso(2 * DAY), NOW)).toBe("fresh"); + }); +}); + +// ── Etherscan empty vs error ────────────────────────────────────────────────── + +describe("parseEtherscanStatus", () => { + test("status 1 → ok", () => { + expect(parseEtherscanStatus("1", "OK").kind).toBe("ok"); + }); + + test("'No transactions found' → empty (legit, not an error)", () => { + expect(parseEtherscanStatus("0", "No transactions found").kind).toBe("empty"); + expect(parseEtherscanStatus("0", "No records found").kind).toBe("empty"); + }); + + test("rate-limit / NOTOK → error (must be surfaced, not swallowed as empty)", () => { + const r = parseEtherscanStatus("0", "Max rate limit reached"); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("rate limit"); + }); + + test("blank message on status 0 → error, not silent empty", () => { + expect(parseEtherscanStatus("0", "").kind).toBe("error"); + }); +}); diff --git a/src/app/api/admin/pipeline-failures/route.ts b/src/app/api/admin/pipeline-failures/route.ts new file mode 100644 index 00000000..887b2375 --- /dev/null +++ b/src/app/api/admin/pipeline-failures/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { internalAuth as authOk } from "@/lib/internal-auth"; +import { getRecentFailures, isPipelineStage, type PipelineStage } from "@/lib/pipeline-observability"; + +// GET /api/admin/registry pipeline failures — where the Bankr data pipeline +// fails, queryable so admin never sees a silent empty state (P5). +// Filters: ?stage= &resolved=true|false &agent= &limit= +export async function GET(req: NextRequest) { + if (!authOk(req)) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(req.url); + const stageParam = url.searchParams.get("stage"); + const resolvedParam = url.searchParams.get("resolved"); + const agent = url.searchParams.get("agent") ?? undefined; + const limitParam = url.searchParams.get("limit"); + + const stage: PipelineStage | undefined = + stageParam && isPipelineStage(stageParam) ? stageParam : undefined; + const resolved = resolvedParam == null ? undefined : resolvedParam === "true"; + const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 200, 1), 500) : 200; + + try { + const failures = await getRecentFailures({ stage, resolved, agentSlug: agent, limit }); + + // Aggregate unresolved counts by stage for a quick overview. + const byStage: Record = {}; + for (const f of failures) { + if (!f.resolved) byStage[f.stage] = (byStage[f.stage] ?? 0) + 1; + } + + return NextResponse.json({ + ok: true, + failures, + counts: { byStage, total: failures.length, unresolved: failures.filter((f) => !f.resolved).length }, + generated_at: new Date().toISOString(), + }); + } catch (err) { + console.error("[api/admin/pipeline-failures] error:", err); + return NextResponse.json({ ok: false, failures: [], error: "Internal error" }, { status: 500 }); + } +} diff --git a/src/app/api/cron/index-observed-truth/route.ts b/src/app/api/cron/index-observed-truth/route.ts index b0c9b427..3f65c52e 100644 --- a/src/app/api/cron/index-observed-truth/route.ts +++ b/src/app/api/cron/index-observed-truth/route.ts @@ -18,6 +18,7 @@ import { NextRequest, NextResponse } from "next/server"; import { internalAuth } from "@/lib/internal-auth"; +import { logPipelineFailure } from "@/lib/pipeline-observability"; import { fetchWalletTransactions } from "@/lib/truth-engine/chain-fetcher"; import { classifyTransaction, computeEvidenceUpgrade } from "@/lib/truth-engine/revenue-classifier"; import { @@ -110,7 +111,20 @@ export async function GET(req: NextRequest) { limit: TX_LIMIT_PER_WALLET, }); - if (fetchResult.provider === "none") { skipped++; continue; } + if (fetchResult.provider === "none") { + skipped++; + // Both providers failed — record it (rate-limit/API error is not "empty"). + await logPipelineFailure({ + stage: "transactions_fetched", + agentSlug: wallet.agent_slug, + walletAddress: addr, + chain, + provider: "none", + error: fetchResult.error ?? "Both providers failed (Alchemy + Etherscan).", + recordsFetched: 0, + }); + continue; + } const ownWallets = await getOwnWallets(wallet.agent_slug); const classified: ClassifiedEvent[] = fetchResult.transactions.map((tx) => @@ -119,14 +133,30 @@ export async function GET(req: NextRequest) { totalEvents += classified.length; + let walletInsertErrors = 0; + let walletWritten = 0; for (const event of classified) { try { await insertRevenueClassificationEvent(wallet.agent_slug, event); + walletWritten++; } catch (e) { insertErrors++; + walletInsertErrors++; if (insertErrors <= 3) errorLines.push(`insert ${wallet.agent_slug}/${addr.slice(0,8)}: ${String(e)}`); } } + if (walletInsertErrors > 0) { + await logPipelineFailure({ + stage: "transactions_classified", + agentSlug: wallet.agent_slug, + walletAddress: addr, + chain, + provider: fetchResult.provider, + error: `${walletInsertErrors} of ${classified.length} classification event(s) failed to persist.`, + recordsFetched: fetchResult.transactions.length, + recordsWritten: walletWritten, + }); + } // Evidence upgrade — only if signals are strong enough try { diff --git a/src/app/api/cron/refresh-books/route.ts b/src/app/api/cron/refresh-books/route.ts index d830de00..9a7acbe7 100644 --- a/src/app/api/cron/refresh-books/route.ts +++ b/src/app/api/cron/refresh-books/route.ts @@ -16,6 +16,7 @@ import { buildAgentBooks, invalidateBooksCache } from "@/lib/agent-books"; import { saveAgentBooksSnapshot } from "@/lib/agent-books-history"; import { internalAuth } from "@/lib/internal-auth"; import { toSlug } from "@/app/registry/[slug]/slug"; +import { logPipelineFailure, markAgentIndexed } from "@/lib/pipeline-observability"; async function notifyTelegram(text: string): Promise { const token = process.env.TELEGRAM_BOT_TOKEN; @@ -50,13 +51,29 @@ export async function GET(req: NextRequest) { const books = await buildAgentBooks(agent, "30d"); if (books.attributed) { await saveAgentBooksSnapshot(books).catch(() => {}); + // Success: stamp freshness and clear any prior failures for this agent. + await markAgentIndexed(slug, agent.name); } else { unattributedAgents.push(`${agent.name} (${books.reason})`); + // Declared wallets but no books — record so it's queryable, not silent. + await logPipelineFailure({ + stage: "books_generated", + agentSlug: slug, + agentName: agent.name, + error: `Unattributed despite declared wallets: ${books.reason}`, + }); } refreshed++; } catch (err) { errors++; - errorAgents.push(`${agent.name}: ${err instanceof Error ? err.message : "unknown"}`); + const msg = err instanceof Error ? err.message : "unknown"; + errorAgents.push(`${agent.name}: ${msg}`); + await logPipelineFailure({ + stage: "books_generated", + agentSlug: slug, + agentName: agent.name, + error: msg, + }); } } diff --git a/src/app/api/luca/skills/treasury-monitor/route.ts b/src/app/api/luca/skills/treasury-monitor/route.ts index 6292c72d..8d7657f9 100644 --- a/src/app/api/luca/skills/treasury-monitor/route.ts +++ b/src/app/api/luca/skills/treasury-monitor/route.ts @@ -9,7 +9,8 @@ export const dynamic = "force-dynamic"; const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; -function healthSignal(balance: number): "healthy" | "low" | "critical" { +function healthSignal(balance: number | null): "healthy" | "low" | "critical" | "unknown" { + if (balance === null) return "unknown"; // lookup failed — not $0 (P5) if (balance >= 10_000) return "healthy"; if (balance >= 1_000) return "low"; return "critical"; @@ -99,8 +100,10 @@ export async function POST(req: NextRequest) { let total = 0; const walletRows = targetWallets.map((w, i) => { - const balance = balances[i].status === "fulfilled" ? balances[i].value : 0; - total += balance; + const result = balances[i]; + const balance = result.status === "fulfilled" ? result.value : null; + // null = lookup failed; don't fold it into the total as a fake $0. + if (balance !== null) total += balance; const elig = isBooksEligibleWallet(w, agent.tokenAddress); return { address: w.address.toLowerCase(), diff --git a/src/app/api/v1/agent-events/route.ts b/src/app/api/v1/agent-events/route.ts index 8afa0cd8..9bbb1c2e 100644 --- a/src/app/api/v1/agent-events/route.ts +++ b/src/app/api/v1/agent-events/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { v1Auth } from "@/lib/v1-auth"; import { logAgentEvent, getAgentEvents } from "@/lib/agent-events"; import type { AgentEventType, AgentEventDirection } from "@/lib/agent-events"; +import { toSlug } from "@/lib/slug"; export const dynamic = "force-dynamic"; @@ -9,18 +10,26 @@ export const dynamic = "force-dynamic"; // Log a new economic event for an agent. // Body: { agentName, eventType, amountUsd?, provider?, token?, direction?, txHash?, metadata? } export async function POST(req: NextRequest) { - const auth = await v1Auth(req); - if (!auth.ok) return auth.response; - let body: Record; try { body = await req.json(); } catch { + // Auth still runs first so a bad key gets 401 before a body error. + const auth = await v1Auth(req); + if (!auth.ok) return auth.response; return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } const { agentName, eventType, amount, provider, token, direction, txHash, walletAddress, metadata } = body; + // Scope enforcement (P8): agent:{slug} keys may only WRITE their own + // events — a scoped key must never log activity under another agent. + const auth = await v1Auth( + req, + typeof agentName === "string" && agentName ? { agentSlug: toSlug(agentName) } : undefined, + ); + if (!auth.ok) return auth.response; + if (!agentName || typeof agentName !== "string") { return NextResponse.json({ error: "agentName is required" }, { status: 400 }); } @@ -48,14 +57,15 @@ export async function POST(req: NextRequest) { // GET /api/v1/agent-events?agentName=X&days=7&limit=100 // Fetch recent events for an agent. export async function GET(req: NextRequest) { - const auth = await v1Auth(req); - if (!auth.ok) return auth.response; - const { searchParams } = req.nextUrl; const agentName = searchParams.get("agentName"); const days = Math.min(90, parseInt(searchParams.get("days") ?? "7", 10)); const limit = Math.min(500, parseInt(searchParams.get("limit") ?? "100", 10)); + // Scope enforcement (P8): agent:{slug} keys read their own events only. + const auth = await v1Auth(req, agentName ? { agentSlug: toSlug(agentName) } : undefined); + if (!auth.ok) return auth.response; + if (!agentName) { return NextResponse.json({ error: "agentName is required" }, { status: 400 }); } diff --git a/src/app/api/v1/agent-report/route.ts b/src/app/api/v1/agent-report/route.ts index 37111971..13ac1789 100644 --- a/src/app/api/v1/agent-report/route.ts +++ b/src/app/api/v1/agent-report/route.ts @@ -2,19 +2,21 @@ import { NextRequest, NextResponse } from "next/server"; import { v1Auth } from "@/lib/v1-auth"; import { getAgentEvents, summarizeEvents } from "@/lib/agent-events"; import { generateAgentReport } from "@/lib/agent-report"; +import { toSlug } from "@/lib/slug"; export const dynamic = "force-dynamic"; // GET /api/v1/agent-report?agentName=X&days=7 // Returns a structured plain-language financial report for an agent. export async function GET(req: NextRequest) { - const auth = await v1Auth(req); - if (!auth.ok) return auth.response; - const { searchParams } = req.nextUrl; const agentName = searchParams.get("agentName"); const days = Math.min(90, parseInt(searchParams.get("days") ?? "7", 10)); + // Scope enforcement (P8): agent:{slug} keys may only read their own report. + const auth = await v1Auth(req, agentName ? { agentSlug: toSlug(agentName) } : undefined); + if (!auth.ok) return auth.response; + if (!agentName) { return NextResponse.json({ error: "agentName is required" }, { status: 400 }); } diff --git a/src/app/luca-admin/page.tsx b/src/app/luca-admin/page.tsx index 773433a6..13fb4ffd 100644 --- a/src/app/luca-admin/page.tsx +++ b/src/app/luca-admin/page.tsx @@ -16,7 +16,7 @@ import type { AddressType } from "@/lib/address-classifier"; // ── Types ───────────────────────────────────────────────────────────────────── -type Section = "overview" | "registry" | "attribution" | "economics" | "growth" | "reports" | "settings" | "subagent-runs" | "truth-engine" | "pending-updates" | "attribution-health" | "address-classification" | "erc8004" | "revenue-audit" | "accuracy-report" | "confidence-labels"; +type Section = "overview" | "bankr-queue" | "registry" | "attribution" | "economics" | "growth" | "reports" | "settings" | "subagent-runs" | "pipeline-failures" | "truth-engine" | "pending-updates" | "attribution-health" | "address-classification" | "erc8004" | "revenue-audit" | "accuracy-report" | "confidence-labels"; type EcoPeriod = "7d" | "30d"; type HealthData = { @@ -142,6 +142,7 @@ const POLICIES = [ const NAV: { section: Section; label: string; group: string }[] = [ { section: "overview", label: "Overview", group: "main" }, + { section: "bankr-queue", label: "Bankr Queue", group: "main" }, { section: "registry", label: "Registry", group: "registry-ops" }, { section: "pending-updates", label: "Pending Updates", group: "registry-ops" }, { section: "attribution", label: "Attribution", group: "registry-ops" }, @@ -156,6 +157,7 @@ const NAV: { section: Section; label: string; group: string }[] = [ { section: "growth", label: "Growth OS", group: "growth" }, { section: "reports", label: "Reports", group: "growth" }, { section: "subagent-runs", label: "Subagent Runs", group: "system" }, + { section: "pipeline-failures", label: "Pipeline Failures", group: "system" }, { section: "settings", label: "Settings", group: "system" }, ]; @@ -3377,20 +3379,313 @@ function SubagentRunsSection({ secret }: { secret: string }) { ); } -// ── Pending Replies ─────────────────────────────────────────────────────────── +// ── Bankr Queue (P6) — one Bankr-only admin queue ──────────────────────────── -const RISK_COLOR: Record = { - low: "var(--accent)", - medium: "#F4B942", - high: "#F46060", +type QueueAuditRow = { + name: string; + slug: string; + x_handle: string | null; + website: string | null; + token_ticker: string | null; + token_address: string | null; + known_wallets: number; + manifest_wallet_count: number; + manifest_status: string; + wallet_status: string; + profile_status: string; + books_status: string; + data_status: string; + metadata_complete_strict: boolean; + outreach_status: string | null; + bankr_priority: string | null; +}; + +type QueueAudit = { + ok: boolean; + from_supabase: boolean; + agents: QueueAuditRow[]; + action_buckets: { + needs_metadata_cleanup: string[]; + needs_manifest: string[]; + ready_for_outreach: string[]; + ready_to_verify: string[]; + }; +}; + +type QueueView = "need_metadata" | "need_manifest" | "stale_books" | "failed_indexing" | "ready_outreach" | "ready_verify"; + +const QUEUE_VIEWS: { key: QueueView; label: string; who: string }[] = [ + { key: "need_metadata", label: "Need Metadata", who: "profile gaps to fill" }, + { key: "need_manifest", label: "Need Manifest", who: "wallets not declared" }, + { key: "stale_books", label: "Stale Books", who: "books cache out of date" }, + { key: "failed_indexing", label: "Failed Indexing", who: "pipeline failures — CTO" }, + { key: "ready_outreach", label: "Ready for Outreach", who: "DM targets — growth" }, + { key: "ready_verify", label: "Ready to Verify", who: "approval queue — Dan" }, +]; + +function queueFilter(rows: QueueAuditRow[], buckets: QueueAudit["action_buckets"], view: QueueView): QueueAuditRow[] { + const inBucket = (b: string[]) => rows.filter((r) => b.includes(r.slug)); + switch (view) { + case "need_metadata": return rows.filter((r) => !r.metadata_complete_strict); + case "need_manifest": return inBucket(buckets.needs_manifest); + case "stale_books": return rows.filter((r) => r.books_status === "stale"); + case "failed_indexing": return rows.filter((r) => r.data_status === "failed"); + case "ready_outreach": return inBucket(buckets.ready_for_outreach); + case "ready_verify": return inBucket(buckets.ready_to_verify); + } +} + +function missingMetadataFields(r: QueueAuditRow): string[] { + const missing: string[] = []; + if (!r.x_handle) missing.push("X handle"); + if (!r.website) missing.push("website"); + if (!r.token_ticker) missing.push("ticker"); + if (!r.token_address) missing.push("token address"); + return missing; +} + +function BankrQueueSection({ secret }: { secret: string }) { + const [audit, setAudit] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [view, setView] = useState("need_manifest"); + + useEffect(() => { + setLoading(true); + fetch("/api/admin/registry/data-audit", { headers: { Authorization: `Bearer ${secret}` } }) + .then((r) => r.json()) + .then((d: QueueAudit & { error?: string }) => { + if (d.ok) setAudit(d); + else setError(d.error ?? "Failed to load audit"); + }) + .catch(() => setError("Network error")) + .finally(() => setLoading(false)); + }, [secret]); + + const rows = audit ? queueFilter(audit.agents, audit.action_buckets, view) : []; + const activeMeta = QUEUE_VIEWS.find((v) => v.key === view); + + return ( +
+
+

Bankr Focus

+

Bankr Queue

+

One queue for every Bankr agent that needs action — metadata, manifests, broken data, outreach, verification.

+
+ + {/* View tabs with live counts */} +
+ {QUEUE_VIEWS.map((v) => { + const n = audit ? queueFilter(audit.agents, audit.action_buckets, v.key).length : 0; + const active = view === v.key; + return ( + + ); + })} +
+

+ {activeMeta?.who} + {view === "failed_indexing" && <> · error detail lives in the Pipeline Failures section} + {audit && !audit.from_supabase && ( + · static fallback data — NOT live + )} +

+ + {loading ? ( +
Loading Bankr queue…
+ ) : error ? ( +
{error}
+ ) : rows.length === 0 ? ( +
Nothing in this queue — no Bankr agents match "{activeMeta?.label}" right now.
+ ) : ( +
+ {rows.map((r) => ( +
+
+ + {r.name} + + {r.bankr_priority && ( + + {r.bankr_priority} + + )} + + wallets {r.known_wallets} · manifest {r.manifest_wallet_count} · books {r.books_status} · data {r.data_status} + + + {view === "ready_outreach" && r.x_handle && ( + + DM {r.x_handle} ↗ + + )} + + profile ↗ + + +
+ {/* Per-view detail line */} + {view === "need_metadata" && ( +

+ missing: {missingMetadataFields(r).join(", ") || "—"} +

+ )} + {view === "ready_verify" && ( +

+ manifest {r.manifest_status} · profile {r.profile_status} · wallet {r.wallet_status} — review submissions in Registry → Manifest Submissions / Claims +

+ )} + {view === "failed_indexing" && ( +

+ data_status: failed — unresolved pipeline failure; see the Pipeline Failures section for the error record +

+ )} +
+ ))} +
+ )} +
+ ); +} + +// ── Pipeline Failures (P5) ──────────────────────────────────────────────────── + +type PipelineFailure = { + id: string; + agent_slug: string | null; + agent_name: string | null; + wallet_address: string | null; + chain: string | null; + provider: string | null; + stage: string; + error: string | null; + records_fetched: number; + records_written: number; + retry_count: number; + resolved: boolean; + created_at: string; }; -const STATUS_BG: Record = { - approved: "var(--accent)", - rejected: "#F46060", - posted: "#4AE8A0", +const PIPELINE_STAGE_LABELS: Record = { + agent_indexed: "Agent Indexed", + metadata_complete: "Metadata Complete", + wallet_declared: "Wallet Declared", + wallet_eligible: "Wallet Eligible", + transactions_fetched: "Transactions Fetched", + transactions_normalized: "Transactions Normalized", + transactions_classified: "Transactions Classified", + books_generated: "Books Generated", + profile_updated: "Profile Updated", + luca_report_generated: "Luca Report Generated", }; +function PipelineFailuresSection({ secret }: { secret: string }) { + const [failures, setFailures] = useState([]); + const [loading, setLoading] = useState(true); + const [resolvedView, setResolvedView] = useState<"unresolved" | "all">("unresolved"); + + useEffect(() => { + setLoading(true); + const q = resolvedView === "unresolved" ? "?resolved=false" : ""; + fetch(`/api/admin/pipeline-failures${q}`, { headers: { Authorization: `Bearer ${secret}` } }) + .then((r) => r.json()) + .then((d: { ok: boolean; failures?: PipelineFailure[] }) => { setFailures(d.failures ?? []); setLoading(false); }) + .catch(() => setLoading(false)); + }, [secret, resolvedView]); + + const byStage: Record = {}; + for (const f of failures) if (!f.resolved) byStage[f.stage] = (byStage[f.stage] ?? 0) + 1; + + return ( +
+
+

Data Reliability

+

Pipeline Failures

+

Every stage failure in the Bankr data pipeline — agent, wallet, chain, provider, stage, error. Fail loud: nothing here is a silent empty state.

+
+ +
+ {(["unresolved", "all"] as const).map((f) => ( + + ))} + {Object.keys(byStage).length > 0 && ( + + {Object.entries(byStage).sort((a, b) => b[1] - a[1]).map(([stage, n]) => ( + + {PIPELINE_STAGE_LABELS[stage] ?? stage}: {n} + + ))} + + )} + + {failures.length} failure{failures.length !== 1 ? "s" : ""} + +
+ + {loading ? ( +
Loading failures…
+ ) : failures.length === 0 ? ( +
+ {resolvedView === "unresolved" + ? "No unresolved pipeline failures. Every stage that has run recently succeeded." + : "No pipeline failures recorded yet."} +
+ ) : ( +
+ {failures.map((f) => ( +
+
+ + + {PIPELINE_STAGE_LABELS[f.stage] ?? f.stage} + + + {f.agent_name ?? f.agent_slug ?? "—"} + + {f.resolved && ( + resolved + )} + + {new Date(f.created_at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })} + +
+
+ {f.wallet_address && wallet {f.wallet_address.slice(0, 8)}…{f.wallet_address.slice(-4)}} + {f.chain && chain {f.chain}} + {f.provider && provider {f.provider}} + fetched {f.records_fetched} + written {f.records_written} + {f.retry_count > 0 && retries {f.retry_count}} +
+ {f.error && ( +

+ {f.error} +

+ )} +
+ ))} +
+ )} +
+ ); +} + // ── Root ────────────────────────────────────────────────────────────────────── export default function LucaAdminPage() { @@ -3549,6 +3844,7 @@ export default function LucaAdminPage() { {/* Workspace */}
{section === "overview" && } + {section === "bankr-queue" && } {section === "registry" && } {section === "attribution" && } {section === "economics" && } @@ -3556,6 +3852,7 @@ export default function LucaAdminPage() { {section === "growth" && } {section === "reports" && } {section === "subagent-runs" && } + {section === "pipeline-failures" && } {section === "pending-updates" && } {section === "attribution-health" && } {section === "address-classification" && } diff --git a/src/app/registry/[slug]/page.tsx b/src/app/registry/[slug]/page.tsx index a80bc060..28f18021 100644 --- a/src/app/registry/[slug]/page.tsx +++ b/src/app/registry/[slug]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; import type { Metadata } from "next"; -import { getRegistryAgents } from "@/lib/registry-db"; +import { getPendingUpdates, getRegistryAgents } from "@/lib/registry-db"; +import { getSupabaseAdminClient, hasSupabaseAdminEnv } from "@/lib/supabase-admin"; import { AGENTS } from "@/app/registry/data"; import type { Agent } from "@/app/registry/types"; import { getAgentEvents, summarizeEvents } from "@/lib/agent-events"; @@ -60,6 +61,28 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str }; } +// P7: pending claim (registry_claims, keyed by agent_name) or manifest +// submission (registry_pending_updates) awaiting admin review for this agent. +async function hasPendingSubmission(agentName: string): Promise { + try { + const updates = await getPendingUpdates(); + if (updates.some((u) => u.agent_name === agentName && u.status === "pending")) return true; + if (hasSupabaseAdminEnv()) { + const sb = getSupabaseAdminClient(); + const { data } = await sb + .from("registry_claims") + .select("id") + .eq("agent_name", agentName) + .eq("status", "pending") + .limit(1); + if ((data ?? []).length > 0) return true; + } + } catch { + // non-fatal — absence of the banner is the safe default + } + return false; +} + async function getLucaEconomics(): Promise { try { const events = await getAgentEvents("luca", 7); @@ -118,6 +141,10 @@ export default async function AgentProfilePage({ params }: { params: Promise<{ s books.attributed ? books.financials.tx_count : 0, ); + // P7: does this agent have a claim or manifest submission waiting on review? + // Non-fatal — a lookup failure just means no pending banner. + const pendingReview = await hasPendingSubmission(agent.name); + // ProfileClient serializes this object into public page HTML — blank the // internal CRM fields (adminNotes stays: the profile renders it as the // public Luca verdict, same as PublicAgent.lucaVerdict). @@ -142,6 +169,7 @@ export default async function AgentProfilePage({ params }: { params: Promise<{ s booksHistory={booksHistory} anomalies={anomalies} verificationScore={verificationScore} + pendingReview={pendingReview} /> ); } diff --git a/src/app/registry/[slug]/profile-client.tsx b/src/app/registry/[slug]/profile-client.tsx index 6e3f3dce..0754a844 100644 --- a/src/app/registry/[slug]/profile-client.tsx +++ b/src/app/registry/[slug]/profile-client.tsx @@ -23,6 +23,7 @@ import { SiteFooter } from "@/components/site-footer"; import { SiteNav } from "@/components/site-nav"; import { agentHealthScore, gradeColor } from "@/lib/agent-health-score"; import { usePrivy, useWallets } from "@privy-io/react-auth"; +import { DOCS_URL } from "@/lib/docs-url"; import type { AgentConfidenceLabel } from "@/lib/revenue-confidence"; import { CONFIDENCE_META } from "@/lib/revenue-confidence"; import { @@ -1433,7 +1434,51 @@ function ToolDecisionsBlock({ events }: { events: ToolDecisionEvent[] }) { type ClaimTab = "wallet" | "manifest"; type ClaimState = "idle" | "loading" | "done" | "error"; -function ClaimBanner({ slug, agentName, status }: { slug: string; agentName: string; status: string }) { +// ── P7: profile CTA row — every unverified profile has a clear next step ───── + +function ProfileCtas({ status, pendingReview }: { status: string; pendingReview: boolean }) { + const verified = status === "Verified" || status === "Luca Managed"; + const claimed = status === "Claimed"; + + const base: React.CSSProperties = { + display: "inline-flex", alignItems: "center", gap: 5, + padding: "6px 12px", borderRadius: 6, fontSize: "0.72rem", fontWeight: 600, + textDecoration: "none", border: "1px solid var(--line)", + color: "var(--ink)", background: "var(--surface)", + fontFamily: "var(--font-mono)", + }; + const primary: React.CSSProperties = { + ...base, + background: "var(--accent)", color: "#fff", border: "1px solid transparent", + }; + + return ( +
+ {pendingReview && ( + + ⏳ Submission pending review + + )} + {!verified && !claimed && !pendingReview && ( + Claim this agent + )} + {!verified && ( + <> + Submit wallet manifest + Declare wallet + + )} + Contact Zetta + View API docs +
+ ); +} + +function ClaimBanner({ slug, agentName, status, pendingReview = false }: { slug: string; agentName: string; status: string; pendingReview?: boolean }) { // Start expanded for unclaimed/unverified profiles — the CTA is the primary action const isUnclaimed = status === "Candidate" || status === "Awaiting Manifest"; const needsAttention = status === "Needs Verification"; @@ -1459,7 +1504,35 @@ function ClaimBanner({ slug, agentName, status }: { slug: string; agentName: str const { wallets } = useWallets(); const connectedWallet = useMemo(() => wallets.find((w) => w.walletClientType !== "privy"), [wallets]); - if (status === "Verified" || status === "Luca Managed" || status === "Claimed") return null; + if (status === "Verified" || status === "Luca Managed") return null; + + // P7: claimed profiles and in-flight submissions show review status instead + // of the claim form — the submitter should see where things stand. + if (status === "Claimed" || pendingReview) { + const heading = status === "Claimed" + ? `${agentName} is claimed — verification in review` + : "Submission received — pending admin review"; + const detail = status === "Claimed" + ? "The team's claim was accepted. Wallet verification and books attribution are being finalized." + : "A claim or wallet manifest for this agent is waiting on admin review (typically 24–48 hours). Questions? Message @zettatracker on X."; + return ( +
+
+ hourglass_top +
+

{heading}

+

{detail}

+
+
+
+ ); + } const bannerHeading = needsAttention ? `Action needed: ${agentName}` @@ -1586,7 +1659,7 @@ function ClaimBanner({ slug, agentName, status }: { slug: string; agentName: str ? "Our team reviews claims within 24–48 hours. Questions? Message @zettatracker on X." : null; return ( -
+
{icon}
@@ -1615,7 +1688,7 @@ function ClaimBanner({ slug, agentName, status }: { slug: string; agentName: str // ── Collapsed state ─────────────────────────────────────────────────────── if (!expanded) { return ( -
+
handshake @@ -1661,7 +1734,7 @@ function ClaimBanner({ slug, agentName, status }: { slug: string; agentName: str // ── Expanded state ──────────────────────────────────────────────────────── return ( -
+
{/* Header */}
handshake @@ -1799,6 +1872,12 @@ function ClaimBanner({ slug, agentName, status }: { slug: string; agentName: str

{mfMsg}

)} +

+ Prefer a guided flow?{" "} + + Register your agent step-by-step → + +

)}
@@ -2082,7 +2161,7 @@ const PROF_TABS: { key: ProfileTab; label: string; badge?: string }[] = [ { key: "research", label: "Research" }, ]; -export function ProfileClient({ agent, slug, economics, inferenceActivity, classification, toolDecisions, books, booksHistory, anomalies = [], verificationScore }: { agent: Agent; slug: string; economics?: AgentEconomicSummary; inferenceActivity?: InferenceSummary; classification?: SettlementClassification; toolDecisions?: ToolDecisionEvent[]; books?: AgentBooks | AgentBooksUnattributed; booksHistory?: AgentBooksSnapshot[]; anomalies?: Anomaly[]; verificationScore?: VerificationScore }) { +export function ProfileClient({ agent, slug, economics, inferenceActivity, classification, toolDecisions, books, booksHistory, anomalies = [], verificationScore, pendingReview = false }: { agent: Agent; slug: string; economics?: AgentEconomicSummary; inferenceActivity?: InferenceSummary; classification?: SettlementClassification; toolDecisions?: ToolDecisionEvent[]; books?: AgentBooks | AgentBooksUnattributed; booksHistory?: AgentBooksSnapshot[]; anomalies?: Anomaly[]; verificationScore?: VerificationScore; pendingReview?: boolean }) { const router = useRouter(); const [showShare, setShowShare] = useState(false); const [showEmbed, setShowEmbed] = useState(false); @@ -2249,6 +2328,9 @@ export function ProfileClient({ agent, slug, economics, inferenceActivity, class })()}
+ {/* P7 — CTA row: every unverified profile has a clear next step */} + + {/* 4 — Financial metrics strip (seamless TL-A grid) */} @@ -2264,7 +2346,7 @@ export function ProfileClient({ agent, slug, economics, inferenceActivity, class {/* When there are no books, the manifest/claim CTA IS the next action — surface it here instead of burying it below empty sections. */} {agent.booksStatus === "no_books" && ( - + )}
@@ -2513,7 +2595,7 @@ export function ProfileClient({ agent, slug, economics, inferenceActivity, class {/* ── Data quality + claim CTA ── */} {agent.booksStatus !== "no_books" && ( - + )} {economics && } diff --git a/src/lib/agent-books.ts b/src/lib/agent-books.ts index 4bf8001b..581233e7 100644 --- a/src/lib/agent-books.ts +++ b/src/lib/agent-books.ts @@ -14,6 +14,7 @@ import { getRegistryAgents } from "@/lib/registry-db"; import { buildLedgerScan } from "@/lib/ledger-service"; import { getWalletStableBalance } from "@/lib/treasury-balance"; +import { logPipelineFailure } from "@/lib/pipeline-observability"; import { toSlug } from "@/app/registry/[slug]/slug"; import { isValidWalletAddress, @@ -269,11 +270,35 @@ async function computeAgentBooks( const ownAddresses = new Set(declared.map((w) => w.address.toLowerCase())); - const scans = await Promise.all( + // Per-wallet isolation: one wallet's scan failure must NOT zero the whole + // agent's books. Log each failure and keep the wallets that succeeded + // (partial books beat no books). If EVERY wallet fails, throw so the agent + // is marked failed upstream (P5). + const scanResults = await Promise.allSettled( scannable.map((w) => buildLedgerScan({ wallet: w.address, range: period, persist: false }), ), ); + const scans: Awaited>[] = []; + scanResults.forEach((r, i) => { + if (r.status === "fulfilled") { + scans.push(r.value); + } else { + void logPipelineFailure({ + stage: "transactions_fetched", + agentSlug: slug, + agentName: agent.name, + walletAddress: scannable[i]?.address, + chain: scannable[i]?.chain ?? "base", + error: r.reason instanceof Error ? r.reason.message : String(r.reason), + }); + } + }); + if (scans.length === 0) { + throw new Error( + `All ${scannable.length} wallet scan(s) failed for ${agent.name} — no transaction data available.`, + ); + } // Any wallet whose tx fetch hit its pagination cap inside the window means // these books may be incomplete — surfaced as a confidence flag below. @@ -476,13 +501,31 @@ async function computeAgentBooks( } // ── Treasury ────────────────────────────────────────────────────────────── + // A failed balance lookup returns null (not 0). If any treasury wallet + // couldn't be fetched, the whole treasury figure is null ("unavailable") + // rather than a fake sum — and the failure is logged (P5: no silent $0). const treasuryWallets = declared.filter((w) => w.role === "treasury" && isValidWalletAddress(w.address)); const treasuryBalances = await Promise.all( - treasuryWallets.map((w) => getWalletStableBalance(w.address).catch(() => 0)), + treasuryWallets.map((w) => getWalletStableBalance(w.address).catch(() => null)), ); - const treasuryBalance = treasuryBalances.length > 0 - ? round(treasuryBalances.reduce((s, b) => s + b, 0)) - : null; + let treasuryBalance: number | null; + if (treasuryWallets.length === 0) { + treasuryBalance = null; + } else if (treasuryBalances.some((b) => b === null)) { + treasuryBalance = null; + const failedIdx = treasuryBalances.findIndex((b) => b === null); + void logPipelineFailure({ + stage: "transactions_fetched", + agentSlug: slug, + agentName: agent.name, + walletAddress: treasuryWallets[failedIdx]?.address, + chain: "base", + provider: "alchemy", + error: "Treasury stablecoin balance lookup failed — treasury reported as unavailable, not $0.", + }); + } else { + treasuryBalance = round((treasuryBalances as number[]).reduce((s, b) => s + b, 0)); + } const runwayMonths = treasuryBalance !== null && expenses > 0 ? round(treasuryBalance / expenses) : null; diff --git a/src/lib/bankr-audit-markdown.ts b/src/lib/bankr-audit-markdown.ts index 160a3772..f2c7218c 100644 --- a/src/lib/bankr-audit-markdown.ts +++ b/src/lib/bankr-audit-markdown.ts @@ -74,6 +74,8 @@ export function renderBankrAuditMarkdown(audit: BankrDataAudit): string { ["Books ready (already generated)", audit.action_buckets.books_ready], ["Needs manifest", audit.action_buckets.needs_manifest], ["Needs metadata cleanup", audit.action_buckets.needs_metadata_cleanup], + ["Ready for outreach (DM targets)", audit.action_buckets.ready_for_outreach], + ["Ready to verify (approval queue)", audit.action_buckets.ready_to_verify], ]; for (const [title, slugs] of buckets) { lines.push(`### ${title} (${slugs.length})`); diff --git a/src/lib/bankr-audit.ts b/src/lib/bankr-audit.ts index 0e98e7d6..f1e2216e 100644 --- a/src/lib/bankr-audit.ts +++ b/src/lib/bankr-audit.ts @@ -52,6 +52,9 @@ export type BankrAgentAuditRow = { needs_review: boolean; is_duplicate: boolean; duplicate_reasons: string[]; // subset of ["token_address","x_handle","wallet_address"] + // CRM fields for the admin queue (P6) — this audit is admin-only, never public. + outreach_status: string | null; + bankr_priority: string | null; }; export type DuplicateGroup = { @@ -94,6 +97,9 @@ export type BankrDataAudit = { needs_manifest: string[]; // not_submitted with known wallets books_ready: string[]; // books already generated (live or stale) usable: string[]; // strict metadata + ≥1 eligible wallet — books can generate now + // P6 queue views: + ready_for_outreach: string[]; // complete metadata, no manifest, not yet contacted — DM targets + ready_to_verify: string[]; // manifest wallets on file, awaiting verification — Dan's approval queue }; }; @@ -191,6 +197,8 @@ export function buildAuditRow( needs_review: agent.metadataStatus === "needs_review", is_duplicate: duplicateReasons.length > 0, duplicate_reasons: duplicateReasons, + outreach_status: agent.outreachStatus, + bankr_priority: agent.bankrPriority, }; } @@ -227,6 +235,21 @@ export function buildActionBuckets(rows: BankrAgentAuditRow[]): BankrDataAudit[" usable: rows .filter((r) => r.metadata_complete_strict && r.eligible_wallet_count > 0) .map((r) => r.slug), + // Growth's DM list: profile is presentable (metadata complete) but has no + // manifest yet, and nobody has reached out. + ready_for_outreach: rows + .filter( + (r) => + r.metadata_complete_strict && + r.manifest_status === "not_submitted" && + r.manifest_wallet_count === 0 && + (r.outreach_status === null || r.outreach_status === "not_contacted"), + ) + .map((r) => r.slug), + // Dan's approval queue: manifest wallets on file, verification not final. + ready_to_verify: rows + .filter((r) => r.manifest_wallet_count > 0 && r.profile_status === "needs_verification") + .map((r) => r.slug), }; } diff --git a/src/lib/pipeline-observability.ts b/src/lib/pipeline-observability.ts new file mode 100644 index 00000000..dc5606dc --- /dev/null +++ b/src/lib/pipeline-observability.ts @@ -0,0 +1,169 @@ +// P5 — pipeline observability. Records where the Bankr data pipeline fails +// so failures are queryable by admin instead of vanishing into empty states. +// +// Golden rule: logging must NEVER break the pipeline. Every write here guards +// on Supabase env and swallows its own errors (console.error only) — a failure +// to log a failure must not itself throw. + +import { getSupabaseAdminClient, hasSupabaseAdminEnv } from "./supabase-admin"; + +// The 10 pipeline stages, in order. Single source of truth — the DB CHECK +// constraint (20260717000001) mirrors this exactly, and the admin API/tests +// import it. +export const PIPELINE_STAGES = [ + "agent_indexed", + "metadata_complete", + "wallet_declared", + "wallet_eligible", + "transactions_fetched", + "transactions_normalized", + "transactions_classified", + "books_generated", + "profile_updated", + "luca_report_generated", +] as const; + +export type PipelineStage = (typeof PIPELINE_STAGES)[number]; + +export type PipelineFailureRow = { + id: string; + agent_slug: string | null; + agent_name: string | null; + wallet_address: string | null; + chain: string | null; + provider: string | null; + stage: PipelineStage; + error: string | null; + last_successful_run: string | null; + records_fetched: number; + records_written: number; + retry_count: number; + resolved: boolean; + resolved_at: string | null; + created_at: string; +}; + +export type LogPipelineFailureParams = { + stage: PipelineStage; + agentSlug?: string | null; + agentName?: string | null; + walletAddress?: string | null; + chain?: string | null; + provider?: string | null; + error: string; + lastSuccessfulRun?: string | null; + recordsFetched?: number; + recordsWritten?: number; + retryCount?: number; +}; + +/** + * Record a pipeline-stage failure. Fire-and-forget: never throws, so callers + * can `await logPipelineFailure(...)` inside a catch without risk. + */ +export async function logPipelineFailure(params: LogPipelineFailureParams): Promise { + if (!hasSupabaseAdminEnv()) { + console.error(`[pipeline-failure:${params.stage}] ${params.error} (not persisted — no Supabase env)`); + return; + } + try { + const sb = getSupabaseAdminClient(); + const { error } = await sb.from("pipeline_failures").insert({ + stage: params.stage, + agent_slug: params.agentSlug ?? null, + agent_name: params.agentName ?? null, + wallet_address: params.walletAddress ?? null, + chain: params.chain ?? null, + provider: params.provider ?? null, + error: params.error.slice(0, 2000), + last_successful_run: params.lastSuccessfulRun ?? null, + records_fetched: params.recordsFetched ?? 0, + records_written: params.recordsWritten ?? 0, + retry_count: params.retryCount ?? 0, + }); + if (error) throw error; + } catch (err) { + // A failure to log a failure is itself non-fatal. + console.error("[pipeline-observability] logPipelineFailure insert failed:", err); + } +} + +/** + * Mark an agent successfully indexed: stamp last_indexed_at (freshness) and + * resolve any open failures for it (success clears the failed state). Never throws. + */ +export async function markAgentIndexed(agentSlug: string, agentName?: string): Promise { + if (!hasSupabaseAdminEnv()) return; + try { + const sb = getSupabaseAdminClient(); + const now = new Date().toISOString(); + + // Stamp freshness on the agent (match by slug or name — registry_agents is + // keyed by name, but the pipeline works in slugs). + if (agentName) { + await sb.from("registry_agents").update({ last_indexed_at: now }).eq("name", agentName); + } + + // Clear open failures for this agent. + await sb + .from("pipeline_failures") + .update({ resolved: true, resolved_at: now }) + .eq("agent_slug", agentSlug) + .eq("resolved", false); + } catch (err) { + console.error("[pipeline-observability] markAgentIndexed failed:", err); + } +} + +/** + * Slugs with at least one unresolved failure — used by getRegistryAgents to + * overlay data_status = "failed". Returns an empty set on any error (non-fatal). + */ +export async function getActiveFailureSlugs(): Promise> { + if (!hasSupabaseAdminEnv()) return new Set(); + try { + const sb = getSupabaseAdminClient(); + const { data, error } = await sb + .from("pipeline_failures") + .select("agent_slug") + .eq("resolved", false) + .not("agent_slug", "is", null); + if (error) throw error; + return new Set((data ?? []).map((r) => (r as { agent_slug: string }).agent_slug)); + } catch (err) { + console.error("[pipeline-observability] getActiveFailureSlugs failed:", err); + return new Set(); + } +} + +export type FailureFilters = { + stage?: PipelineStage; + resolved?: boolean; + agentSlug?: string; + limit?: number; +}; + +export async function getRecentFailures(filters: FailureFilters = {}): Promise { + if (!hasSupabaseAdminEnv()) return []; + try { + const sb = getSupabaseAdminClient(); + let q = sb + .from("pipeline_failures") + .select("*") + .order("created_at", { ascending: false }) + .limit(filters.limit ?? 200); + if (filters.stage) q = q.eq("stage", filters.stage); + if (filters.resolved !== undefined) q = q.eq("resolved", filters.resolved); + if (filters.agentSlug) q = q.eq("agent_slug", filters.agentSlug); + const { data, error } = await q; + if (error) throw error; + return (data ?? []) as PipelineFailureRow[]; + } catch (err) { + console.error("[pipeline-observability] getRecentFailures failed:", err); + return []; + } +} + +export function isPipelineStage(v: string): v is PipelineStage { + return (PIPELINE_STAGES as readonly string[]).includes(v); +} diff --git a/src/lib/registry-db.ts b/src/lib/registry-db.ts index 02846d2c..febc3c17 100644 --- a/src/lib/registry-db.ts +++ b/src/lib/registry-db.ts @@ -2,6 +2,7 @@ import { getSupabaseAdminClient, hasSupabaseAdminEnv } from "./supabase-admin"; import { toSlug } from "./slug"; import { AGENTS } from "@/app/registry/data"; import { deriveBooksStatus, deriveDataStatus } from "./status-tags"; +import { getActiveFailureSlugs } from "./pipeline-observability"; import type { Agent, AgentWallet, WalletLabel, Ecosystem, Health, VerificationStatus, OutreachStatus, FocusStatus, BankrPriority, MetadataStatus, WalletStatus, ProfileStatus, @@ -27,6 +28,7 @@ interface RegistryAgentRow { partnership_fit_score: number | null; outreach_status: string | null; last_checked: string | null; + last_indexed_at: string | null; // pipeline freshness marker (P5) admin_notes: string | null; priority: number | null; pfp: string | null; @@ -108,6 +110,7 @@ function rowToAgent( wallets: AgentWallet[], commIdentities?: CommunicationIdentity[], booksComputedAt?: string | null, + hasActiveFailure = false, ): Agent { const eligibleWalletCount = (wallets ?? []).filter((w) => w.booksEligible).length; return { @@ -131,7 +134,9 @@ function rowToAgent( walletStatus: (row.wallet_status as WalletStatus) ?? "none", profileStatus: (row.profile_status as ProfileStatus) ?? "candidate", booksStatus: deriveBooksStatus(eligibleWalletCount, booksComputedAt ?? null), - dataStatus: deriveDataStatus(row.last_checked), + // Freshness reads the pipeline's last_indexed_at (falls back to last_checked); + // an unresolved pipeline failure overrides both → "failed" (P5). + dataStatus: deriveDataStatus(row.last_indexed_at ?? row.last_checked, Date.now(), hasActiveFailure), lastChecked: row.last_checked, adminNotes: row.admin_notes, priority: row.priority ?? 50, @@ -143,12 +148,11 @@ function rowToAgent( // ── Agent → DB row mapper ───────────────────────────────────────────────────── -// wallet_status and profile_status are intentionally absent: they are -// trigger-owned in the DB, so writes from app code would be overwritten -// (and seeds must not fight the triggers). +// wallet_status and profile_status are trigger-owned; last_indexed_at is +// pipeline-owned (stamped by markAgentIndexed). None are written from seeds. function agentToRow( agent: Agent, -): Omit { +): Omit { return { name: agent.name, slug: agent.slug, @@ -190,7 +194,7 @@ export async function getRegistryAgents(): Promise<{ agents: Agent[]; fromSupaba try { const sb = getSupabaseAdminClient(); - const [agentsResult, walletsResult, commResult, booksCacheResult] = await Promise.all([ + const [agentsResult, walletsResult, commResult, booksCacheResult, activeFailures] = await Promise.all([ sb .from("registry_agents") .select("*") @@ -198,6 +202,8 @@ export async function getRegistryAgents(): Promise<{ agents: Agent[]; fromSupaba sb.from("registry_agent_wallets").select("*"), sb.from("registry_agent_comm_identities").select("*"), sb.from("agent_books_cache").select("agent_slug, computed_at").eq("period", "30d"), + // Unresolved pipeline failures → data_status overlay (P5). Non-fatal. + getActiveFailureSlugs(), ]); if (agentsResult.error) throw agentsResult.error; @@ -242,14 +248,16 @@ export async function getRegistryAgents(): Promise<{ agents: Agent[]; fromSupaba commByAgent[c.agent_name].push(rowToCommIdentity(c)); } - const agents = agentRows.map((row) => - rowToAgent( + const agents = agentRows.map((row) => { + const slug = row.slug ?? toSlug(row.name); + return rowToAgent( row, walletsByAgent[row.name] ?? [], commByAgent[row.name] ?? [], - booksComputedAtBySlug[row.slug ?? toSlug(row.name)] ?? null, - ) - ); + booksComputedAtBySlug[slug] ?? null, + activeFailures.has(slug), + ); + }); return { agents, fromSupabase: true }; } catch (err) { diff --git a/src/lib/status-tags.ts b/src/lib/status-tags.ts index cc31a61d..8a35aba9 100644 --- a/src/lib/status-tags.ts +++ b/src/lib/status-tags.ts @@ -42,8 +42,12 @@ export function deriveBooksStatus( export function deriveDataStatus( lastChecked: string | null | undefined, now: number = Date.now(), + hasActiveFailure = false, ): DataStatus { - // "failed" is reserved: no refresh-error marker exists in the schema yet. + // An unresolved pipeline failure wins over freshness (P5). The failure + // signal is passed in from getRegistryAgents (a read of pipeline_failures) — + // this stays a pure function. + if (hasActiveFailure) return "failed"; if (!lastChecked) return "partial"; const age = now - new Date(lastChecked).getTime(); if (Number.isNaN(age)) return "partial"; diff --git a/src/lib/treasury-balance.ts b/src/lib/treasury-balance.ts index 9750dffa..2e8319fd 100644 --- a/src/lib/treasury-balance.ts +++ b/src/lib/treasury-balance.ts @@ -10,13 +10,17 @@ const ALCHEMY_BASE = "https://base-mainnet.g.alchemy.com/v2"; const _cache = new Map(); const TTL = 10 * 60 * 1000; // 10 minutes -export async function getWalletStableBalance(walletAddress: string): Promise { +// Returns the stablecoin balance, or null when the balance could NOT be +// fetched (no API key, network/HTTP error, timeout). null ≠ 0: a genuine +// empty wallet returns 0, a failed lookup returns null so callers render +// "unavailable" instead of a fake $0. (P5 — no silent empty states.) +export async function getWalletStableBalance(walletAddress: string): Promise { const addr = walletAddress.toLowerCase(); const cached = _cache.get(addr); if (cached && cached.expires > Date.now()) return cached.balance; const apiKey = process.env.ALCHEMY_API_KEY; - if (!apiKey) return 0; + if (!apiKey) return null; try { const res = await fetch(`${ALCHEMY_BASE}/${apiKey}`, { @@ -31,7 +35,7 @@ export async function getWalletStableBalance(walletAddress: string): Promise }; @@ -51,6 +55,6 @@ export async function getWalletStableBalance(walletAddress: string): Promise